Skip to content

feat(AIC-3439)!: let wildcard handlers accept only named providers - #136

Merged
andrewklatzke merged 8 commits into
mainfrom
aklatzke/AIC-3439/scoped-handlers
Oct 30, 2026
Merged

andrewklatzke merged 8 commits into
mainfrom
aklatzke/AIC-3439/scoped-handlers

Conversation

@andrewklatzke

@andrewklatzke andrewklatzke commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A wildcard handler can take a providers list, so a LangChain adapter used only for Bedrock accepts config.provider.name == "Bedrock" and no other provider.
  • Exact [provider, mode] still wins. Among scoped wildcards that contain the name, the shortest list wins, then the earlier registration. An unscoped * is the fallback.
  • Registry identity is provides_for plus the provider set, so two scoped wildcards of the same mode can be registered together. Judges, graph selection, and the evaluations runner use the same order.

Test plan

  • uv run pytest on the client, langchain-messages, and langchain-agents suites
  • ruff and mypy on the touched packages
  • Integration success and failure for agent, graph, streaming, graph-streaming, judge, langchain, langchain-messages, langchain-agents, and native-graph-langchain

Made with Cursor

Changelog

This PR changes core routing and the evaluations runner. 1.0 is not published, so both lines are breaking and land in that cut:

BEGIN_COMMIT_OVERRIDE
feat(AIC-3439)!: let wildcard handlers accept only named providers
feat(evaluations)!: route generation and judges to a scoped wildcard handler only for its named providers, and accept two scoped wildcards of one mode when their lists differ
END_COMMIT_OVERRIDE

A LangChain adapter used for one provider no longer has to match every provider, and two scoped wildcards of the same mode can be registered together.

Co-authored-by: Cursor <cursoragent@cursor.com>
@andrewklatzke

Copy link
Copy Markdown
Contributor Author

Human message:

I think this is a "nice to have" not necessarily "must do it". I'm thinking of situations where, for example, a user might want to use the Google ADK and scope it only to gemini (create_google_adk_handler(providers=['Gemini'])). Another example might be folks wanting to use langchain but only for Bedrock so they don't have to deal with boto.

You can technically achieve this now by properly including the right subset of handlers, but this makes it more obvious/colocates it with the code if you want to scope them down.

tldr; if we think this is confusing or don't want it, we can close it

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The routing code is close, and it matches js #111 on almost every probe case. This PR also fixes a Python bug. On main, run_judges used the parent handler for any judge whose provider no handler covered, because the no-match branch fell through to judge_handler = handler (main judges.py:110-147). An OpenAI parent would run an Anthropic judge. build_judge_tasks already skipped that case. Two things need doing before this merges: a rebase that fixes two tests, and agreement with #118 on how the evaluations runner picks handlers.

At ee8f6f0c I get 1427 passed and 11 skipped. mypy (51 files), ruff and ruff format --check are clean.

  1. Two new tests fail once this is merged with today's main. Git merges it without conflicts. But test_scoped_wildcard_does_not_run_a_judge_outside_its_list and test_two_scoped_judge_handlers_divide_providers (test_evaluations_run.py:2100 and :2133) call init_evaluations(api_token=...) and pass project_key to run(). Since this branch was cut, main has renamed the argument to api_key (7a238e5) and moved project_key onto init_evaluations (I found #127 / be1e64a with git log -S, but didn't read the commit). After I fixed both calls, the merged tree gives 1505 passed and 11 skipped, with mypy (52 files) and ruff clean. The green CI ran on the old base.
  2. #118 conflicts with this, in the code and in what it does. Merging this into #118 (4cd1e0ee) conflicts in evaluations/runner.py and packages/client/README.md. #118 replaces _find_judge_handler and _covers_provider with a single _select_handler, and its rules contradict this PR in two ways. First, "a single handler always runs, whatever it declares", so a lone Bedrock-scoped handler would run an Anthropic judge. Second, two wildcard candidates raise "can't disambiguate" instead of the shortest list winning. Whichever PR lands second has to rebuild the scoped order inside _select_handler. I'd land this one first and have #118 call select_mode_handler. #39 §3.13 already says that a judge outside every scoped list raises EvaluationsError naming the provider.
  3. Bugbot's untyped-parent finding is real, but I'd keep the new behaviour. I reproduced it. With run_judges(handler=untyped, handlers=[untyped]), the judge ran on main and is skipped now. graph() picks a lone untyped handler through the non-strict fallback, so that node's judges are now dropped without a log line. But main's build_judge_tasks already skipped the same case (0 tasks in my probe), and JS skips it too, because parentCovers is false without providesFor (judges.ts:25-28). This same continue is also what stops an OpenAI parent from running an Anthropic judge. So the change makes Python consistent with itself and with JS. I'd ask for two things: log when a judge is skipped because no handler fits (neither SDK says anything today), and add a changelog line. The evaluations runner still treats an untyped generation handler as covering every judge (runner.py:179-180). That's a separate path and fine as it is, but #39 should say so.
  4. Python replaces a re-registered handler in place, and JS moves it to the end. registry.py:74 keeps the slot, while js registry.ts:29 filters and appends. Ties go to the earlier registration, so the SDKs now choose differently. As a repro, register ['Bedrock', 'Gemini'], then ['Bedrock', 'Anthropic'], then ['Bedrock', 'Gemini'] again. Python then runs the Gemini-list handler for Bedrock, and JS runs the Anthropic-list one. compose differs the same way. We need one rule in #39 §3.6. I'd keep Python's.
  5. select_handler now drops matches that aren't ProviderHandler instances (utils.py:601). On main, the exact and wildcard steps returned any object with provides_for. Now isinstance(chosen, ProviderHandler) throws the match away, and config() raises Handler for provider OpenAI with mode messages not found even though a matching handler is registered. I probed this with a duck-typed ('*', 'messages') object. If only ProviderHandler is supported, the error message is misleading. Otherwise, drop the check and use a cast for mypy.
  6. Repeated names count toward list length (utils.py:97). frozenset makes ['Bedrock', 'Bedrock'] the same identity as ['Bedrock'], but best_wildcard ranks it by len(tuple(allow)), so it ties with ['Bedrock', 'Anthropic']. Deduping in normalize_providers fixes this. JS has the same issue.
  7. The docstring and the code disagree on whitespace. utils.py:26-28 says surrounding whitespace is rejected rather than trimmed, but [' Bedrock'] is accepted (and never matches). Either reject it or fix the docstring. JS needs the same change.

I tried 23 mutations, and 17 of them fail a test. The 6 that get through are in the inline comments: unscoped beating scoped, the strict coverage check, _provider_matches, the runner's _covers_provider and its shortest-list sort, and accepting a bare string.

Smaller items

  • ProviderHandler.__init__ takes providers positionally and doesn't validate or copy it (types.py:169, :175). ProviderHandler(fn, pf, providers=[]) builds a handler that matches nothing. Please run it through normalize_providers, or make it keyword-only. This signature freezes at 1.0 too.
  • Exact handlers that have a list keep a separate identity (registry.py:20). The list is ignored for routing on an exact handler, so two ('OpenAI', 'messages') handlers should collide, as #39 says. JS does the same.
  • The duplicate warning prints a frozenset repr: Handler for (('*', 'messages'), frozenset({'Gemini', 'Bedrock'})) already registered. The name order changes from run to run. Sorting the names for the message would fix that.
  • Public surface. providers is a keyword-only argument on create_handler, create_langchain_messages_handler and create_langchain_agents_handler, and an attribute on ProviderHandler, with the same name and meaning as JS. No new names reach __all__ (normalize_providers, wildcard_covers, best_wildcard, select_mode_handler and covers_provider_name aren't exported), so py #144's ROOT_SURFACE snapshot isn't affected.
  • #107 overlap. Merging this into #107 (c76acd58) conflicts in utils.py, where model_parameters and the new helpers are both additions, and in the collections.abc import line of both LangChain handlers (Mapping vs Sequence). Keep both sides. I don't see a clash in behaviour: #107 is about parameter forwarding, not routing.
  • graph_judge passes handlers here (graph.py:1016-1024 and :1238-1250), which is right. JS doesn't, so I've asked for that on js #111.

Parity with js-ai-sdk #111

The two SDKs agree on selection order, ties, case, a None / "" provider, empty and blank lists, both strict error strings (Handler for provider OpenAI not found and ... with mode agent not found), and set identity. They differ in four places:

  • re-registration position (4)
  • the graph-level judge (JS is wrong)
  • copying the caller's list (Python copies, JS doesn't)
  • ['a,b'] vs ['a', 'b'], which collide in JS and stay separate here

andrewklatzke and others added 4 commits October 21, 2026 10:15
…ped-handlers

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	packages/client/src/launchdarkly_ai_server/types.py
#	packages/client/src/launchdarkly_ai_server/utils.py
#	packages/langchain-agents/src/launchdarkly_ai_langchain_agents/graph.py
#	packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py
#	packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py
…ct handler identity

- Reject provider names with surrounding whitespace; dedupe and copy the list.
- A judge with no covering handler is skipped with a warning.
- An empty handlers list no longer bypasses the scoped-parent check.
- Exact handlers collide on (provider, mode) whatever their list.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ped-handlers

# Conflicts:
#	packages/client/src/launchdarkly_ai_server/judges.py
Resolves the conflicts with #152, which routes evaluations through
utils.select_handler and removed judge_handlers.

- The scoped order now lives in utils.select_mode_handler, which
  select_handler calls, so config(), graph(), online judges and the
  evaluations runner share it: exact provider, then the shortest scoped
  list containing the provider, then unscoped '*'. It is built on main's
  handler_key, so completion and messages still match.
- The runner's own _find_judge_handler / _covers_provider are gone; the
  scoped runner tests are ported to handlers=[...].
- Registry and the evaluations duplicate check share utils.registration_key,
  so two scoped wildcards of one mode with different lists are both
  accepted, and the same list in another order is rejected.
- run_judge logs the same warning as run_judges when no handler covers a
  judge's provider.
- normalize_providers moves to types.py so ProviderHandler.__init__ needs
  no inline import; utils imports it from there.
- API reports regenerated for client, langchain-agents and
  langchain-messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeffdupont
jeffdupont requested a review from a team as a code owner October 23, 2026 19:24
@jeffdupont

Copy link
Copy Markdown
Contributor

Pushed 0d0e893, which merges origin/main and resolves the conflicts with #152:

  • The scoped order is in utils.select_mode_handler, built on main's handler_key. select_handler calls it, so config(), graph(), online judges and the evaluations runner all route the same way: exact provider, then the shortest scoped list that contains the provider, then unscoped *.
  • The runner's own selection code is gone, and its scoped tests are ported to handlers=[...].
  • The registry and the evals duplicate check share utils.registration_key.
  • run_judge now logs the same warning as run_judges when it skips a judge.
  • normalize_providers moved to types.py, so there is no inline import in ProviderHandler.__init__.
  • I ran make api-report: client, langchain-agents and langchain-messages changed.
  • I added a BEGIN_COMMIT_OVERRIDE to the description with a separate feat(evaluations): line.

This needs a human reviewer.

@jeffdupont
jeffdupont dismissed their stale review October 26, 2026 17:56

Both items are addressed in 0d0e893 (merge with main; scoped order moved into select_mode_handler). Dismissing my own stale request; this needs a second approver since I pushed code.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I merged main and moved the scoped order into select_mode_handler. Since I pushed those changes, I'm dismissing my 10-08 change request. @andrewklatzke, please check the merge; this needs a second approver.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving 0d0e893. The change is additive (keyword-only providers on create_handler and both LangChain factories), the API reports are updated, and it matches launchdarkly/ai-sdks-monorepo#39. Note: the branch now conflicts with main, so it needs a main merge before it can land.

@jeffdupont

Copy link
Copy Markdown
Contributor

My approval was at 0d0e893. Main has moved since, and the branch now conflicts in five files: api-report/launchdarkly_ai_server.txt, evaluations/module.py, types.py, and the langchain-agents and langchain-messages handler.py. Please merge main, regenerate the API report and push. I'll re-check the merge resolution only.

@jeffdupont jeffdupont changed the title feat(AIC-3439): let wildcard handlers accept only named providers feat(AIC-3439)!: let wildcard handlers accept only named providers Oct 29, 2026
…ped-handlers

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	packages/client/api-report/launchdarkly_ai_server.txt
#	packages/client/src/launchdarkly_ai_server/evaluations/module.py
#	packages/client/src/launchdarkly_ai_server/types.py
#	packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py
#	packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

create_handler already carried an inner ProviderHandler's stream_fn and
capture_content override forward when the caller omitted them; providers
was not, so re-wrapping a scoped wildcard handler silently became
unscoped. Bugbot caught this on the latest commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
@andrewklatzke

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fa001bd. Configure here.

…ped-handlers

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	packages/client/src/launchdarkly_ai_server/utils.py
#	packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py
@andrewklatzke
andrewklatzke merged commit 454a4de into main Oct 30, 2026
9 checks passed
@andrewklatzke
andrewklatzke deleted the aklatzke/AIC-3439/scoped-handlers branch October 30, 2026 17:06
jeffdupont added a commit that referenced this pull request Oct 30, 2026
Steps 1 and 2 of
[AIC-3672](https://launchdarkly.atlassian.net/browse/AIC-3672). The JS
half is AIC-3673.

## What this does

- **`release-as: 1.0.0`** on all 14 packages in
`release-please-config.json`. Without it, release-please keeps bumping
0.x minors, so the next release PR would ship `launchdarkly-ai-server`
0.6.0 instead of 1.0.0.
- **Internal pins.** Every package that depends on
`launchdarkly-ai-server` now requires `>=1.0.0,<2`. release-please
doesn't update these for Python. #218 (merged) set them to `>=0.6,<2` so
either release order resolves; this tightens them to the 1.0 line.
- **`uv.lock`** needs no change: uv records workspace dependencies
without a version specifier. `uv lock --check` passes.

`changelog-sections` is left alone. #235 removes the evaluations
Experimental rows when it promotes Evaluations from Code to core.

## Merge this last

Merge this after the PRs below. Anything merged before the release PR
(#231) merges ships in 1.0.0, and a `!` change merged after 1.0 forces
2.0.

- [x] #218 fix(AIC-3598)!: packaging and public types (merged 29 Oct)
- [x] #235 feat(evaluations)!: promote Evaluations from Code to core
(merged 29 Oct). After it merges, nothing in
`launchdarkly_ai_server.experimental` is public.
- [x] #237 fix(claude-agents)!: honour max_tokens and disable thinking
by default (merged 29 Oct)
- [ ] #136 feat(AIC-3439): wildcard handlers accept only named providers
(approved; conflicts with main, needs a merge of main)
- [x] #234 fix(AIC-3568)!: give the barrel core's `__all__` (merged 29
Oct)
- [ ] #246 fix(AIC-3592): make native LangGraph hand-offs complete (spec
launchdarkly/ai-sdks-monorepo#118)
- [ ] #248 fix(AIC-3573): never send a NativeTool to the model as a
function tool (spec launchdarkly/ai-sdks-monorepo#116)
- [ ] #240 feat(client): add result variant and value to the
feature_flag span event (approved, mergeable)
- [ ] #251 fix(AIC-3716): build OpenAI native graph node tools with
FunctionTool (review required; touches the same loop as #248)

#241 (Shadow Requests) is closed; 1.0 ships with nothing experimental.

## After merging

1. Confirm the regenerated #231 lists 1.0.0 for all 14 packages, then
merge it.
2. Install each package from PyPI into an empty venv and import its
public entry points (AIC-3672 step 5).
3. Open a PR removing `release-as` straight away, or every later release
PR proposes 1.0.0 again.

## Checks

- `release-please-config.json` parses as JSON, with `release-as: 1.0.0`
on all 14 packages. The first push wrote the lines with literal
backslashes; fixed in d720896.

- `make test`: 3,711 passed, 18 skipped (before #175 and #236 merged; CI
is the current check) (with the parent checkout's `.env` LD URIs
blanked; see AIC-3593)
- `uv lock --check` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)


[AIC-3672]:
https://launchdarkly.atlassian.net/browse/AIC-3672?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ




<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Prepares the **1.0.0 GA** cut for all 14 Python packages by teaching
release-please to release **1.0.0** instead of another **0.x** bump.
> 
> Adds **`release-as: "1.0.0"`** on every entry in
`release-please-config.json` (core `launchdarkly-ai-server`, barrel, and
all handler packages).
> 
> Tightens **internal PyPI pins** from `launchdarkly-ai-server>=0.6,<2`
to **`>=1.0.0,<2`** in each handler `pyproject.toml` and in
`packages/ai` (including **`otel`** / **`ld`** extras). Published wheels
will require the 1.0 server line once those versions ship.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ae9f2cc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: andrewklatzke <aklatzke@launchdarkly.com>
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.

2 participants