Skip to content

feat(llm): add DecisionModel with OTEL tracing - #6068

Merged
jpshackelford merged 10 commits into
mainfrom
feat/decision-model
Oct 30, 2026
Merged

jpshackelford merged 10 commits into
mainfrom
feat/decision-model

Conversation

@jpshackelford

@jpshackelford jpshackelford commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

An OpenHands customer is using decision models in their OpenHands agent code but wishes that the SDK supported them because they miss the OTEL visibility they have with the rest of their agent work. LiteLLM now supports them and this PR adds a facade and models so that like our LLM class so that LiteLLM client code is kept to a single entry point.


AGENT:

Why

Calls to decision models (classifier-style models that answer typed questions about a state) go through litellm.systemone, and Laminar's LiteLLM instrumentation only traces chat and responses calls. A decision-model call therefore leaves no span, and callers get none of the SDK's retries, exception mapping or metrics either.

DecisionModel runs those calls with the same retry rules, exception mapping, Metrics and SecretStr handling as LLM, and wraps each attempt in a decision.<model> span. The span records the questions, answers, token counts and cost, but not the state, which may be sensitive. Questions, answers and replies are SDK types. LiteLLM is the default provider, used only inside DecisionModel.send() and asend(). A subclass can override those two methods to call a provider LiteLLM does not support, and it keeps the retries, metrics and span.

Summary

  • Add DecisionModel with decide() and adecide(), the Noul, Choice and Score question types, and typed DecisionResult answers.
  • Ask many questions about one state in a single call. questions is a dict of named questions of any mix of types, and the result holds one answer per name. Through LiteLLM a call takes at most 128 questions and a Choice at most 255 options. The LiteLLM path raises LLMBadRequestError before sending if a call has more.
  • Accept JSON structure (a string, object or list) in question instructions, Choice option descriptions and Score levels, and an optional NoulCriteria that says what makes a Noul true or false.
  • Match LiteLLM's question schema: instructions is optional (a Noul needs instructions or criteria), and a Choice option may have no description.
  • Raise LLMResponseError, without retry, when a reply is missing an answer, has an answer of the wrong type, or has an answer nobody asked for.
  • Forward custom_llm_provider, extra_headers, safety_identifier and the AWS credential fields LLM has to LiteLLM.
  • Make the provider call replaceable. send() and asend() take SDK questions and return an SDK DecisionReply with answers, the model that answered, DecisionUsage and cost. DecisionResult.model records the model that answered.
  • Reuse LLM's retry rules (through a shared transient_error_condition() helper), exception mapping, metrics and secret handling, for every provider. A custom send() raises LLMRateLimitError, LLMTimeoutError or LLMServiceUnavailableError for failures worth retrying.
  • Emit a decision.<model> span, nested under the active span, with questions, answers, tokens and cost.
  • Export DecisionModel and DecisionResult from openhands.sdk, and all new names from openhands.sdk.llm.

Issue Number

Refs #6015

How to Test

uv run pytest tests/sdk/llm/test_decision.py runs the real call path against a local HTTP server and checks the request format, all three answer types, sync and async, retry on 429, the mapped LLMRateLimitError when retries run out, metrics and cost, redaction of the API key, and that the span nests under an active Laminar span with the expected attributes. A DecisionModel subclass that overrides send() and asend() gets the same checks: typed results, metrics, retries on SDK errors, the missing-answer error and the span. Branch coverage of decision.py is 98%.

Against the live Jev API (typesafe/jev-latest, LiteLLM 1.105.1), one decide() call with a Noul, a Choice and a Score question returned an answer for each (refund 0.98, team technical, impact 1.0) from jev-1.13.0, using 411 input and 68 output tokens at a cost of $0.0000173. One call with 100 mixed questions returned all 100 answers in 0.10 s for $0.0002, about $0.000002 per question. One question alone cost $0.0000132.

Video/Screenshots

None.

Design Doc

None.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Builds on #6030 (LiteLLM 1.105.1): litellm.systemone first ships in LiteLLM 1.104.3, and the tests here need it. Examples are in #6339 and documentation is in OpenHands/docs#1054. Both will be rebased onto this PR's latest API.

This PR was drafted by an AI agent on behalf of the user.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:d7bf909-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-d7bf909-python \
  ghcr.io/openhands/agent-server:d7bf909-python

All tags pushed for this build

ghcr.io/openhands/agent-server:d7bf909-golang-amd64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-golang-amd64
ghcr.io/openhands/agent-server:feat-decision-model-golang-amd64
ghcr.io/openhands/agent-server:d7bf909-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:d7bf909-golang-arm64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-golang-arm64
ghcr.io/openhands/agent-server:feat-decision-model-golang-arm64
ghcr.io/openhands/agent-server:d7bf909-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:d7bf909-java-amd64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-java-amd64
ghcr.io/openhands/agent-server:feat-decision-model-java-amd64
ghcr.io/openhands/agent-server:d7bf909-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:d7bf909-java-arm64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-java-arm64
ghcr.io/openhands/agent-server:feat-decision-model-java-arm64
ghcr.io/openhands/agent-server:d7bf909-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:d7bf909-python-amd64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-amd64
ghcr.io/openhands/agent-server:feat-decision-model-python-amd64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-amd64
ghcr.io/openhands/agent-server:d7bf909-python-arm64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-arm64
ghcr.io/openhands/agent-server:feat-decision-model-python-arm64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-arm64
ghcr.io/openhands/agent-server:d7bf909-python-minimal-amd64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-minimal-amd64
ghcr.io/openhands/agent-server:feat-decision-model-python-minimal-amd64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:d7bf909-python-minimal-arm64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-minimal-arm64
ghcr.io/openhands/agent-server:feat-decision-model-python-minimal-arm64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:d7bf909-python-slim-amd64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-slim-amd64
ghcr.io/openhands/agent-server:feat-decision-model-python-slim-amd64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:d7bf909-python-slim-arm64
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-slim-arm64
ghcr.io/openhands/agent-server:feat-decision-model-python-slim-arm64
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:d7bf909-golang
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-golang
ghcr.io/openhands/agent-server:feat-decision-model-golang
ghcr.io/openhands/agent-server:d7bf909-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:d7bf909-java
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-java
ghcr.io/openhands/agent-server:feat-decision-model-java
ghcr.io/openhands/agent-server:d7bf909-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:d7bf909-python-minimal
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-minimal
ghcr.io/openhands/agent-server:feat-decision-model-python-minimal
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-minimal
ghcr.io/openhands/agent-server:d7bf909-python-slim
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python-slim
ghcr.io/openhands/agent-server:feat-decision-model-python-slim
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime-slim
ghcr.io/openhands/agent-server:d7bf909-python
ghcr.io/openhands/agent-server:d7bf909ec0125088ea18a0dfbb2cfd10f724a518-python
ghcr.io/openhands/agent-server:feat-decision-model-python
ghcr.io/openhands/agent-server:d7bf909-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., d7bf909-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., d7bf909-python-amd64) are also available if needed

@jpshackelford
jpshackelford added this pull request to stack #6069 October 17, 2026 22:20
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk
   __init__.py28293%115–116
openhands-sdk/openhands/sdk/llm
   decision.py234399%567–569
   llm.py11096594%767, 806–807, 812, 922, 938, 1178, 1263, 1342, 1499, 1627, 1913, 1917–1918, 2117, 2134, 2219, 2223–2224, 2305, 2326, 2394, 2407, 2425, 2439, 2441, 2443, 2468, 2470, 2479–2480, 2504, 2651, 2807–2808, 2921, 3234–3235, 3244, 3295–3296, 3304, 3307, 3310–3311, 3320, 3325, 3389, 3391, 3393–3396, 3398–3401, 3406–3409, 3424, 3435, 3495, 3497
openhands-sdk/openhands/sdk/llm/utils
   retry_mixin.py741185%71, 74, 96, 154, 158, 162–163, 175, 178–179, 184
TOTAL53602857784% 

@jpshackelford
jpshackelford force-pushed the feat/decision-model branch 2 times, most recently from 519c752 to 6c27cfd Compare October 18, 2026 01:29
@jpshackelford
jpshackelford marked this pull request as ready for review October 18, 2026 02:02
@jpshackelford
jpshackelford marked this pull request as draft October 18, 2026 02:03
Comment thread tests/sdk/llm/test_chat_options.py Resolved
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  This PR no longer adds or changes files under `.pr/`.

Base automatically changed from upgrade-litellm-1.105.1 to main October 30, 2026 04:50
@jpshackelford
jpshackelford force-pushed the feat/decision-model branch 2 times, most recently from 30de7b2 to 6b49cc0 Compare October 30, 2026 04:52
@jpshackelford

jpshackelford commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Validation: decision calls in Laminar traces

Tested 7ab1860d against the live Jev API (typesafe/jev-latest, served by jev-1.14.0). Traces went to Laminar Cloud and to a local Jaeger, through the SDK's own maybe_init_laminar() setup.

Result: pass. Each decision call shows in Laminar as an LLM span. The span sits under the active span, including the tool span in an agent run. Its Input pane shows the questions and its Output pane shows the answers. The state is in no span.

Setup

  1. Real code paths: DecisionModel.decide() and adecide() against Jev. Inside an agent run, a custom TriageTool whose executor calls decide(). The agent's LLM was Claude Sonnet 4.5.
  2. Replaced parts: a local stand-in provider at base_url, only for the retry rows. It returns 500, 500, then 200, or one fixed error.
  3. Recorded: every state held a unique marker string. A span is clean when no attribute or event contains the marker.

What I checked

Nesting in an agent run ✅ conversation → agent.step → TriageAction → jev-1.14.0 in Laminar, next to the agent's own llm.* spans (screenshot 3)
Input and Output panes ✅ Input: {"questions": {...}}. Output: the typed answers, e.g. "team": {"choice": "technical", "confidence": 0.98, ...} (screenshot 3)
State excluded ✅ All spans are clean in Jaeger, across 5 traces (sync, retries, async, auth failure, root). In Laminar, the tool's decision span shows the questions only
Tokens ✅ The span reports 391 → 61. Laminar adds it to the parent (401 → 62 with the stand-in call's 10 → 1, screenshot 1)
Cost ✅ gen_ai.usage.cost is about 0.000014 per call. Laminar reads this attribute when it is above 0. Its display precision shows the value as $0.0
One span per attempt ✅ Stand-in 500, 500, then 200: 2 error spans with litellm.InternalServerError, then 1 span with answers (screenshot 1)
Failure ✅ Bad API key: the decision span and its parent are marked as errors. The exception event is openhands.sdk.llm.exceptions.types.LLMAuthenticationError (screenshot 2)
Concurrency ✅ 3 adecide() calls with asyncio.gather, and 3 decide() calls from a thread pool: each appears as a separate child of its parent span
Retry and error mapping ✅ Stand-in, num_retries=3: 500 and 503 → 3 attempts, LLMServiceUnavailableError. Quota 429 and 400 → 1 attempt, LLMRateLimitError / LLMBadRequestError. A 5 s delay with timeout=1 → 3 attempts, LLMTimeoutError
1. Retries (qa-g-standalone) 2. Auth failure (qa-g-auth-failure) 3. Agent run
qa-g-standalone trace qa-g-auth-failure trace Agent run trace

Click a screenshot to open it at full size.


This comment was drafted by an AI agent (OpenHands) on behalf of John-Mason Shackelford.

@jpshackelford

jpshackelford commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Validation: DecisionModel inputs, errors and metrics

Tested 7ab1860d against the live Jev API (typesafe/jev-latest, served by jev-1.14.0), and against a local stand-in provider for responses that Jev cannot be made to send.

Result: pass, with 2 findings. All three answer types work, sync and async. Invalid input and provider errors become the SDK's exceptions with the expected number of attempts. Metrics add up across calls. The findings are in this PR's response handling and score answer type, listed at the end.

Setup

  1. Real code paths: DecisionModel.decide() and adecide() against Jev, with Noul, Choice and Score questions.
  2. Replaced parts: a stand-in provider at base_url. The URL path selects one response: an error status, a slow reply, a body that is not JSON, an unknown answer type, or a reply without some answers. The stand-in counts the HTTP requests it receives. An attempt is one request.
  3. Retry settings: num_retries=3 and zero wait, unless a row says otherwise.

What I checked

Answers, live ✅ One call with all three types on a refund ticket: refund 0.98, team technical (0.98), impact 1.01. adecide() returns the same types
Inputs Jev accepts ✅ state as "", a list, or a nested dict. Question names with spaces, / or é. A Choice with 1 option. A Score with 1 or 10 levels
Inputs rejected before a request ✅ Score with 0 or 11 levels, Choice with no options, changing a frozen question, or an unknown DecisionModel field: ValidationError
Inputs LiteLLM rejects ✅ questions={}, state=None, or a set as state: LLMBadRequestError ("Invalid Decisions request")
Configuration errors ✅ No API key: LLMAuthenticationError ("Missing API key for Decisions provider 'typesafe'"). Unknown Jev model: LLMBadRequestError. Provider without decisions support (anthropic/...): LLMBadRequestError that lists the supported providers. With api_key unset, LiteLLM's TYPESAFE_API_KEY fallback works
Request ✅ POST /v1/systemone with Authorization: Bearer <key> and body {"model","state","questions"}. Each SDK attempt sends exactly 1 request, so LiteLLM's own retries are off
Retry-After ✅ 429 with Retry-After: 1 and retry_max_wait=0: 1 attempt, then LLMRateLimitError. Same 429 with retry_min_wait=8 and num_retries=2: 2 attempts, 8.0 s apart
Connection refused ✅ LLMServiceUnavailableError
Bad provider responses ✅ Unknown answer type or a body that is not JSON: 3 attempts, then LLMServiceUnavailableError. A reply without usage: answers returned, cost 0
Metrics ✅ Live call: 409 → 68 tokens, cost 0.0000172. Two calls on one model: model.metrics holds both, and the first result's snapshot does not change. A call that succeeds on attempt 3 records 1 latency
Observability ✅ No OTEL or Laminar variables: the call works and lmnr is not imported. Trace endpoint unreachable: the call returns in 0.12 s, and the exporter logs Exception while exporting Span

Findings

Both are in this PR's code, not in LiteLLM.

Finding Cause Suggested action
⚠️ Missing answers. The stand-in returns 200 with "answers": {} for question u. decide() succeeds with answers={}. An accessor for a missing name raises a bare KeyError: 'nope' LiteLLM passes the reply through. _finish does not compare the answers with the questions Fix in this PR. Raise LLMResponseError that names the missing questions. Give the accessor's KeyError a message that lists the known names
⚠️ Score probability keys. ScoreAnswer.probabilities is {"0": 0.0, "1": 0.99, "2": 0.01}. The index-to-level text is only in raw_response LiteLLM's score answer has a legend field. The SDK's ScoreAnswer does not copy it Decide before merge. Add legend: dict[str, str] to ScoreAnswer, or key probabilities by level text. ScoreAnswer is a public type, so a later change breaks callers

Two inputs fail the same way on LLM, so I suggest no change. Plain-dict questions raise AttributeError: 'dict' object has no attribute 'model_dump'; LLM.completion() with dict messages raises AttributeError: 'dict' object has no attribute 'to_chat_dict'. timeout=0 passes validation and then fails at once with LLMServiceUnavailableError, on both classes.


This comment was drafted by an AI agent (OpenHands) on behalf of John-Mason Shackelford.

@jpshackelford
jpshackelford marked this pull request as ready for review October 30, 2026 05:53
@jpshackelford

Copy link
Copy Markdown
Member Author

Rough edges from manual testing: what changed

Manual testing of 7ab1860d found 2 rough edges in this PR's code and 2 inputs that fail the same way as LLM. The 2 rough edges are fixed. The LLM-consistent cases stay as they are.

Rough edge Action Check
A reply without an answer for a requested question succeeded with fewer answers. The accessor then raised a bare KeyError Fixed in f4e843ed. decide() and adecide() raise LLMResponseError: Decision model returned no answer for: team. The call is not retried, and its tokens and cost are still recorded. The accessor's KeyError lists the answers that exist test_missing_answer_raises_without_retry_and_keeps_usage, test_answer_accessor_rejects_wrong_type_and_unknown_name
ScoreAnswer.probabilities is keyed by level index, and the index-to-level text was only in raw_response Fixed in 10be6874. ScoreAnswer has a new legend field, copied from LiteLLM. The probabilities keys do not change Live Jev: probabilities={'0': 0.0, '1': 0.75, '2': 0.25}, legend={'0': 'Cosmetic only', '1': 'Workaround available', '2': 'Work blocked'}
Plain-dict questions raise AttributeError No change. LLM.completion() with dict messages also raises AttributeError. Both classes take SDK types only Same error on LLM: 'dict' object has no attribute 'to_chat_dict'
timeout=0 passes validation, then fails at once with LLMServiceUnavailableError No change. LLM has the same rule (ge=0) and the same failure Same error on LLM

99e1920a adds 2 tests that the manual testing showed were missing. The retries-exhausted test now runs for adecide() too. A new test sends a reply with no usage: LiteLLM fills in zero tokens, and the span gets no cost. Line and branch coverage of decision.py goes from 93% to 96%. Two of the paths still not covered cannot run through LiteLLM, because it rejects unknown answer types and always fills in usage. The others are a debug log for a failing tracer and the re-raise of an unmapped error in adecide().


This comment was drafted by an AI agent (OpenHands) on behalf of John-Mason Shackelford.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Scope. In-repository change: a provider-neutral DecisionModel facade in the SDK's LLM layer, per linked issue #6015 (closed, ready-for-dev). The SDK's only LiteLLM dependency is the generic systemone/asystemone entry point; no provider-specific code lives in the SDK, so the "new AI providers" checkpoint does not apply. Additive public API only (openhands.sdk.llm and openhands.sdk exports); no persisted-settings or event-shape changes.

Verification on the exact head (99e1920a).

  • uv run pytest tests/sdk/llm/test_decision.py — 10/10 pass, exercising the real call path against a local HTTP server (request format, all three answer types, sync/async, retry on 429, exhaustion → LLMRateLimitError, missing-answer LLMResponseError without retry, metrics/cost, key redaction, span nesting and attributes).
  • Full tests/sdk/llm: 1409 passed; the 2 failures in tests/sdk/llm/auth/test_credentials.py are pre-existing, environment-dependent (Path.home() double-join), and in files this PR does not touch.
  • Pre-commit on the changed files (Ruff format/lint, pycodestyle, pyright) passes; no # type: ignore in the new module.
  • GitHub Actions on this head: 15 runs, 0 failures (sdk-tests, cross-tests, build-binary-and-test on all three OSes, integration-test, REST/OpenAPI breakage checks all green).

Findings. None material. The secrets path uses the canonical validate_secret/serialize_secret helpers with the key redacted in dumps/repr and never placed on spans; retry classification mirrors LLM (LLM_RETRY_EXCEPTIONS + transient HTTP, quota exhaustion excluded, LiteLLM retries disabled via max_retries=0); each attempt gets its own decision.<model> span nested under the active span, and the potentially sensitive state is deliberately excluded — all confirmed by the author's live Laminar validation comments.

One non-blocking pre-merge item, already acknowledged by the author: the PR body says "Documentation is in OpenHands/docs#TBD". The SDK package guidance requires a corresponding OpenHands/docs PR for new public API surfaces — please file and cross-reference it before merge.

✅ APPROVED

@jpshackelford

jpshackelford commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Gaps found while writing the examples, closed in 709460c

I found these while writing the examples in #6339 against the Jev docs.

1. Questions could only be plain strings. Jev's build guide recommends JSON structure in questions. For example, a Choice option can describe what it covers, what it is not for, and give examples. A Noul can also say what makes the answer true and what makes it false. LiteLLM 1.105.1 already accepts all of this: its DecisionsJSON type allows a string, an object or a list for instructions and criteria, and NoulQuestion has an optional criteria with true and false. The gap was only in our types, which took str.

The fix:

  • instructions on all three question types, Choice option descriptions and Score levels now accept a string, an object or a list (DecisionJSON).
  • Noul takes an optional criteria=NoulCriteria(true=..., false=...).
  • ScoreAnswer.legend now holds whatever level values were sent. Before this, structured levels would have failed when the answer was converted.
  • Unset optional fields are left out of the request, so plain-string questions send the same JSON as before.

2. LiteLLM caps a call at 128 questions. Jev's docs say a request has no limit on the number of questions, only on size. LiteLLM's MAX_DECISION_QUESTIONS is 128, and LiteLLM rejects a 129-question call with BadRequestError before sending it. decide() and adecide() now check this limit first and raise a ValueError. The error says to split the questions across calls that send the same state. The decide() docstring states the limit, and the docs for DecisionModel should state it too.

Still not supported: Box. LiteLLM adds box questions and TypeSafe image input in v1.106.0. The SDK caps LiteLLM below 1.106 because 1.106 needs mcp>=2 (#6088), so Box waits for that upgrade.

Validation

  • Unit tests: tests/sdk/llm/test_decision.py adds two tests and tightens one. The new tests send structured questions through the real call path to a local HTTP server, and check that 129 questions fail with no request sent. The tightened test now checks the exact request JSON for plain-string questions. All 12 tests pass, and so do the other 1,401 tests in tests/sdk/llm.
  • Live Jev (jev-1.14.0): One call through DecisionModel combined structured instructions, NoulCriteria, object Choice descriptions, object Score levels and a plain-string Noul. All four answers came back, and the Score legend returned the level objects that were sent.

This comment was drafted by an AI agent on behalf of the user.

@jpshackelford

Copy link
Copy Markdown
Member Author

DecisionModel was tied to LiteLLM; d202caf removes the tie

I found this while planning an example that adds a decision model LiteLLM does not support.

The problem. This design means LiteLLM is the default provider, not the only one. The code did not allow any other provider:

  • decide() and adecide() called litellm.systemone directly, and nothing could replace that call.
  • DecisionResult.raw_response had LiteLLM's DecisionsResponse type. So a result from another provider meant building LiteLLM objects by hand.
  • LiteLLM cannot help either. Its decision providers are a fixed list in ProviderConfigManager.get_provider_decisions_config, with no way to register a new one.

So a provider LiteLLM lacks could only be added outside DecisionModel. That loses the retries, metrics and decision.<model> span, and the span is the main reason to use DecisionModel.

The fix.

  • send() and asend() are new public methods that make one provider request. They take the SDK's question types and return a new SDK type, DecisionReply. It holds the answers, the model that answered, DecisionUsage (tokens) and cost, plus the raw payload. The default methods send through LiteLLM, and they are now the only code that uses LiteLLM.
  • decide() and adecide() call send() / asend() once per attempt. They wrap each call in the same retries, error mapping, metrics, missing-answer check and span, whichever provider answers.
  • DecisionResult.model records the versioned model that answered. raw_response is now the provider's untyped payload. For LiteLLM it is still the DecisionsResponse.
  • Errors from a custom send(): SDK errors pass through unchanged. LLMRateLimitError, LLMTimeoutError and LLMServiceUnavailableError are retried.
  • The 128-question limit is LiteLLM's, so only the LiteLLM path checks it.

Adding a provider is now a subclass:

class AcmeDecisionModel(DecisionModel):
    def send(self, state, questions) -> DecisionReply:
        payload = call_acme_api(state, questions)  # the provider's own wire format
        return DecisionReply(
            answers={name: NoulAnswer(probability=p) for name, p in payload["yes"].items()},
            model=payload["model"],
            usage=DecisionUsage(input_tokens=payload["tokens"]),
        )

For a generally available model, a LiteLLM provider is still the better route. It reaches SDK users when we upgrade LiteLLM, and every other LiteLLM user too. The subclass suits private, internal, or not-yet-supported models. Example 62 in #6339 will show both routes.

Validation

  • Tests: test_decision.py adds ten test cases built on a real DecisionModel subclass that overrides send() and asend(). They check typed results and metrics, sync and async. They also check retries on the three SDK errors, that other errors are raised unchanged with no retry, the missing-answer error, that the 128-question limit does not apply, and the span with and without model, usage and cost. The LiteLLM-path tests now also check result.model and raw_response. All 22 tests pass, and so do the other 1,437 tests in tests/sdk/llm and tests/sdk/observability.
  • Coverage: branch coverage of decision.py went from 96% to 98%, measured the way CI measures it. The three lines still uncovered are defensive. One handles a LiteLLM answer type that LiteLLM 1.105.1 cannot return. The other two handle a failure while writing span attributes.
  • Live Jev: a decide() call through the default LiteLLM path still returns all three answer types, tokens and cost.

This comment was drafted by an AI agent on behalf of the user.

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if we should do that

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Scope. Unchanged and in-repository: a provider-neutral DecisionModel facade in the SDK's LLM layer per issue #6015 (ready-for-dev, priority:high). LiteLLM remains the default provider behind the generic systemone/asystemone entry point; the new send()/asend() override hook keeps any provider-specific knowledge in subclasses, not in the SDK. Additive public API only; no persisted-settings or event-shape changes.

Verification on the exact head (2eb3abbc).

  • uv run pytest tests/sdk/llm/test_decision.py — 22/22 pass, exercising real code paths: the LiteLLM path against a local HTTP server and the new send()/asend() subclass hook against scripted replies (typed results, metrics including cache tokens and cost, retry on the three documented SDK errors, non-transient errors passing through unchanged, missing-answer LLMResponseError without retry, the LiteLLM 128-question limit failing before any request, and span nesting/attributes for both paths).
  • Full tests/sdk/llm: 1412 passed. The two known failures in tests/sdk/llm/auth/test_credentials.py are pre-existing and environment-dependent (Path.home() double-join) and in files this PR does not touch.
  • Pre-commit on the changed files (Ruff format/lint, pycodestyle, pyright, dynamic-attribute guard, import dependency rules) passes.
  • GitHub Actions on this head: every completed run succeeds (11), including pre-commit, REST/OpenAPI breakage, persisted-settings compatibility, TypeScript CI, and docstring checks; the remaining two are image-build/test-matrix runs still in progress with no failures. The earlier "Review Thread Gate" failure reflects the then-unresolved threads, which are now resolved.
  • Verified against the installed LiteLLM 1.105.1 that MAX_DECISION_QUESTIONS (128) matches LiteLLM's own max_length on the questions mapping, so the SDK's pre-request ValueError mirrors the provider's real constraint; and that litellm.systemone accepts exactly the str | Mapping | Sequence state and question-dict shapes the facade passes.

Findings. None material. The secrets path, retry classification (LiteLLM errors + transient HTTP, quota exhaustion excluded), span contents (questions/answers/tokens/cost on the span; state deliberately excluded) and metrics accumulation are unchanged from the previously reviewed head and remain correct. Both review threads from the human reviewer are addressed in d202caf4 (public send()/asend() extension point with docstring contract) and 2eb3abbc (_QuestionBase consolidation, dead import removed). One non-blocking pre-merge item, already acknowledged by the author: the required corresponding OpenHands/docs PR is still "TBD" per the PR body.

✅ APPROVED

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

went through decision.py once more and left a few comments. the raise from e and the json.dumps ones look like real bugs to me, the rest is smaller.

Comment thread tests/sdk/llm/test_decision.py Resolved
@jpshackelford

Copy link
Copy Markdown
Member Author

Related PRs for DecisionModel:

Both will be rebased onto this PR's latest commit (70245fdc8) and checked for API changes from the review fixes, such as optional instructions, LLMBadRequestError for LiteLLM's limits, and the new provider fields.

This comment was posted by an AI agent (OpenHands) on behalf of @jpshackelford.

openhands-agent and others added 10 commits October 30, 2026 09:17
… spans

Co-authored-by: openhands <openhands@all-hands.dev>
Laminar shows a span's Input and Output panes from lmnr.span.input and
lmnr.span.output. Chat calls get them from the child litellm.completion
span, which LiteLLM instrumentation does not create for systemone, so
decision spans showed raw attributes only. Set the questions as input and
the answers as output on the decision span itself, and record
gen_ai.response.model. The state stays off the span.

Co-authored-by: openhands <openhands@all-hands.dev>
A reply that omitted a requested question used to succeed with fewer
answers, and the accessor then raised a bare KeyError. decide() and
adecide() now raise LLMResponseError naming the missing questions. The
call is not retried, and its tokens and cost are still recorded. The
accessor's KeyError lists the answers that exist.

Co-authored-by: openhands <openhands@all-hands.dev>
ScoreAnswer.probabilities is keyed by level index, and the map from index
to level text was only on raw_response. Copy LiteLLM's legend onto
ScoreAnswer so callers can read the level names from the SDK type.

Co-authored-by: openhands <openhands@all-hands.dev>
Run the retries-exhausted test for adecide() as well as decide(), so the
async path's exception mapping is exercised. Add a test for a reply with
no usage: LiteLLM fills in zero tokens, and no cost is recorded.

Co-authored-by: openhands <openhands@all-hands.dev>
…odel

LiteLLM's decision types accept JSON structure (a string, object or list)
for question instructions, Choice option descriptions and Score levels, and
an optional true/false criteria on Noul questions. DecisionModel only took
plain strings, so these Jev features were out of reach. Widen the question
types to match, add NoulCriteria, and type ScoreAnswer.legend so structured
levels come back intact. Unset optional fields stay off the wire.

LiteLLM also rejects calls with more than 128 questions. Check that limit
before sending, with an error that says to split the questions across calls
with the same state, and document it on decide().

Co-authored-by: openhands <openhands@all-hands.dev>
DecisionModel called litellm.systemone directly and returned LiteLLM's
DecisionsResponse in DecisionResult.raw_response, so a provider LiteLLM
does not support could not be added without LiteLLM types.

Move the provider call into public send() and asend() methods that take the
SDK's question types and return a new SDK DecisionReply (answers, model,
DecisionUsage, cost, raw payload). The default methods send through LiteLLM,
and they are now the only code that touches LiteLLM. decide() and adecide()
wrap each send in the same retries, error mapping, metrics, missing-answer
check and decision.<model> span, whichever provider answers.

DecisionResult gains model, the versioned model that answered, and
raw_response becomes the provider's untyped payload. SDK errors that a custom
send() raises pass through unchanged, and LLMRateLimitError, LLMTimeoutError
and LLMServiceUnavailableError are retried. LiteLLM's 128-question limit is
now checked only on the LiteLLM path.

Co-authored-by: openhands <openhands@all-hands.dev>
…otations

Co-authored-by: openhands <openhands@all-hands.dev>
- Keep the original exception chain when decide()/adecide() re-raise an
  SDK or unmapped error, as LLM does.
- Build span JSON only when a span exists, once per attempt, and never let
  it fail the call; set each span attribute independently.
- Mirror LiteLLM's question schema: instructions are optional (a Noul
  needs instructions or criteria), Choice options may lack a description.
- Raise LLMBadRequestError for LiteLLM's 128-question and 255-option
  limits, in the LiteLLM send path only.
- Raise LLMResponseError for answers of the wrong type or that nobody
  asked for, without retry.
- Drop frozen=True from the question models.
- Document DecisionResult.metrics as the model's running totals.
- Share the retry condition with LLM via transient_error_condition().
- Drop the max_retries kwarg, which systemone never reads.
- Forward custom_llm_provider, extra_headers, safety_identifier and AWS
  credentials to LiteLLM.
- Fail the test server loudly on requests beyond the scripted replies.

Co-authored-by: openhands <openhands@all-hands.dev>
Add tests for the state staying off the decision span, one span per retry
attempt, spans on failed and async calls, HTTP 5xx/401/400/quota handling on
the LiteLLM path, forwarded provider options, model validation, cost and
metrics edge cases, and API key leakage.

Add a strict xfail for non-JSON values in the state or questions, which are
retried as a service outage instead of failing fast as a bad request.
@jpshackelford
jpshackelford enabled stacked auto-merge (squash) October 30, 2026 14:32
@jpshackelford
jpshackelford merged commit c048a1e into main Oct 30, 2026
68 of 70 checks passed
@jpshackelford
jpshackelford deleted the feat/decision-model branch October 30, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants