Skip to content

[api][plan][python][examples] Framework-managed LLM-as-judge routing (Strategies.llm) - #1042

Open
purushah wants to merge 2 commits into
apache:mainfrom
purushah:model-routing-v2
Open

[api][plan][python][examples] Framework-managed LLM-as-judge routing (Strategies.llm)#1042
purushah wants to merge 2 commits into
apache:mainfrom
purushah:model-routing-v2

Conversation

@purushah

@purushah purushah commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What

Implements the Strategies.llm(...) follow-up promised in discussion #897 and documented as the framework-managed follow-up in the #964 javadocs: the engine — not the strategy — executes the judge call, on the same durable, metered, observable chat path as any model call.

env.addResource("judge", ResourceType.CHAT_MODEL, judgeModelDescriptor);  // any chat model

env.addResource("router", ResourceType.MODEL_ROUTER,
    ModelRouter.of("small", "big")
        .describe("small", "fast and cheap: chit-chat, lookups")
        .describe("big",   "expensive: code, SQL, multi-step reasoning")
        .strategy(Strategies.llm("judge"))
        .defaultModel("small")
        .fallback(true)
        .build());

Design (per the #897 agreement)

  • LlmJudgeRoutingStrategy carries configuration plus two pure functions (prompt build, verdict parse); route() is never invoked — the resolver detects the strategy and runs the judge chat under durable id "judge:<router>" (engine retries, trace events, token attribution to the judge model), then persists the decision — with its source and judge-inclusive decision_ms — under the standard "route:<router>" id. Judge abstains persist as abstains, so replay after a candidate-set change resolves to the current default exactly like the strategy path.
  • Verdicts are constrained to candidate names: the judge prompt is built from the router's candidates + describe(...) lines; an unparseable, ambiguous (multiple distinct candidates named), or non-candidate verdict abstains to the default model with the cause recorded. A hijacked judge cannot steer routing outside the declared candidates.
  • Failure policy: a judge call that exhausts its retries honors the request's error-handling strategy (FAIL surfaces the outage loudly, IGNORE abstains to the default with the cause recorded); interrupts/cancellation propagate and are never persisted as routing outcomes (including the InterruptedIOException shapes HTTP stacks use — with SocketTimeoutException explicitly treated as an ordinary failure, not cancellation).
  • Fail-fast validation: build() constructs the strategy so its argument rules apply at the registration call site, and AgentPlan validates at plan construction — by instantiating the strategy exactly as the runtime does — that the judge name resolves to a registered CHAT_MODEL. A judge setup with a bound prompt, tools, or skills is rejected (it would silently corrupt every verdict). Plan-time validation never instantiates non-judge custom strategies (gated by assignability before construction; regression-tested).
  • Python: add_resource(..., MODEL_ROUTER) now raises an explicit not-yet-supported error instead of dropping silently (flagged in the [api][plan][runtime][examples] Add pluggable in-chat model routing (MODEL_ROUTER) #964 review); a cross-language test proves Java plans carrying the new strategy args still deserialize in Python.

Compatibility impact

  • Additive only: plain-chat agents and existing rule/custom routers are untouched.
  • Event consumers see a new decision_source value llm_judge and new optional metadata keys (judge_model, judge_prompt_tokens, judge_completion_tokens, decision_source).
  • Deliberate trade-off: the judge path performs a second durable write (route:<router> in addition to judge:<router>) to keep the route-record schema uniform across router types and preserve replayed decision_ms.

Test evidence

  • 685 Java tests green (new: verdict routing incl. durable ids + metadata, ambiguity/injection/parse table, judge retry budget, failure policy under FAIL and IGNORE, prompt-bound judge rejection and IGNORE degradation, plan-time validation incl. subclasses and null tolerance, prompt-template handling, promptArgs-shaped requests); Python suites green incl. the cross-language round-trip.
  • End-to-end on a standalone Flink 2.3.0 cluster (dist jar + flink run of the new ModelRoutingJudgeExample, local Ollama): judge verdicts routed correctly with full metadata; fallback composition exercised live.

Generative AI was used for this change.

Generated-by: Claude Code 2.1.239 (Claude Fable 5)

…(Strategies.llm)

The follow-up promised in discussion apache#897 and the v1 javadoc: the engine —
not the strategy — executes the judge call, on the same durable, metered,
observable chat path as any model call.

- Strategies.llm(judgeModel[, promptTemplate]): declarative built-in; the
  judge is any registered CHAT_MODEL; candidate describe(...) lines are its
  decision criteria.
- LlmJudgeRoutingStrategy carries config plus two pure functions (prompt
  build, verdict parse); route() throws — it is never invoked. The verdict
  parser scans every "model" match for a candidate, so a chatty judge that
  quotes the format contract still parses.
- ModelRoutingResolver runs the judge via the invoker under durable id
  "judge:<router>" (engine retries, trace events, token attribution to the
  judge model), derives the decision as a pure function of the verdict, and
  persists it — with its source and judge-inclusive decision_ms — under the
  standard "route:<router>" id; replayed decisions are guarded against
  candidate-set changes like the strategy path.
- Failure policy: unparseable or non-candidate verdicts abstain to the
  default model; a judge that exhausts its retries honors the request's
  error-handling strategy (FAIL is loud, IGNORE degrades to the default
  with the cause recorded); interrupts propagate.
- Shared retry-policy helpers on ChatModelInvoker (used by both the chat
  path and the judge path).
- Python: add_resource(MODEL_ROUTER) now raises an explicit
  not-yet-supported error instead of dropping silently (per apache#964 review).
- ModelRoutingJudgeExample mirrors ModelRoutingExample with a judge.

Generated-by: Claude Code 2.1.239 (Claude Fable 5)
@github-actions github-actions Bot added doc-label-missing The Bot applies this label either because none or multiple labels were provided. fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Aug 23, 2026
A typo'd judge model name would not fail the job: every judge call would
fail and (under IGNORE) abstain to the default model, silently disabling
routing. All resources are known at plan construction, so fail there
instead — same fail-fast standard as the router/chat-model name-clash
check. Validation instantiates the strategy exactly as the runtime does
(instanceof dispatch + getJudgeModel()), so subclasses are judged by what
they actually return; anything not instantiable at plan time is left to
the runtime's own error. Null-tolerant for previously-legal constructor
inputs.

Generated-by: Claude Code 2.1.239 (Claude Fable 5)
@github-actions github-actions Bot added doc-label-missing The Bot applies this label either because none or multiple labels were provided. and removed doc-label-missing The Bot applies this label either because none or multiple labels were provided. labels Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-label-missing The Bot applies this label either because none or multiple labels were provided. fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant