Skip to content

FEAT: Allow creation and display of RoundRobinTarget in GUI#1944

Open
jsong468 wants to merge 5 commits into
microsoft:mainfrom
jsong468:rr_gui
Open

FEAT: Allow creation and display of RoundRobinTarget in GUI#1944
jsong468 wants to merge 5 commits into
microsoft:mainfrom
jsong468:rr_gui

Conversation

@jsong468
Copy link
Copy Markdown
Contributor

@jsong468 jsong468 commented Jun 5, 2026

Description

Improves the GUI experience for RoundRobinTarget across three surfaces: the Target Configuration table, the Create Target dialog, and the Chat window target badge. Also fixes duplicate inner targets in TargetsInitializer caused by overlapping .env entries.

Target Configuration Table

  • Expandable inner targets: RoundRobinTarget rows show a chevron (▶/▼) in the Type column that expands to reveal sub-rows for each inner target, showing their type, model/deployment name, endpoint, modalities, capabilities, and weight.
  • Active target summary: The pinned active target row at the top also supports expand/collapse, sharing state with the main table row.
  • Model column: Shows the shared model_name only when all inner targets have the same deployment name; otherwise shows "—" to avoid misleading display.
  • Extracted InnerTargetRows component: Sub-row rendering is shared between the active summary and main table to eliminate duplication.
image

Create Target Dialog

  • RoundRobinTarget creation: Added RoundRobinTarget to the target type dropdown. When selected, the endpoint/model/auth fields are replaced with a target picker that lets users select existing targets from the registry.
  • Compatibility pre-filtering: After the first target is selected, the dropdown filters to only show compatible targets (same target_type, underlying_model_name, temperature, top_p). Backend performs definitive validation on submit.
  • Weight configuration: Each selected target has a weight input (default 1) and a remove button. Submit is disabled until ≥2 targets are selected.
  • Hidden irrelevant hints: The .pyrit_conf auto-populate hint is hidden for RoundRobinTarget since users are picking existing targets, not configuring new ones.
image

Chat Window Badge & Tooltip

  • Informative badge: Shows RoundRobinTarget (gpt-4o ×3) using underlying_model_name (the shared model) rather than a potentially inconsistent deployment name.
  • Inner Targets section: Tooltip includes an "Inner Targets" section listing each target's type, deployment name, endpoint, and weight.
image

Backend Changes

  • TargetInstance model: Added optional inner_targets: list[TargetInstance] field for composite target support.
  • Target mapper: Recursively maps inner targets into the DTO. Hoists model_name only when all inner targets agree; hoists underlying_model_name when uniform.
  • Target service: create_target_async handles RoundRobinTarget by resolving target_registry_names from the registry and delegating all validation to the RoundRobinTarget constructor.
  • Initializer dedup: _auto_group_targets now deduplicates inner targets by ComponentIdentifier.hash before creating round-robin groups. This prevents duplicate entries when .env files have overlapping configurations that resolve to identical targets (e.g., two env var blocks pointing to the same endpoint + model + key). Previously, these duplicates wasted rotation slots in the round-robin without adding any load-balancing benefit.

Drift Guard

  • Added TestFrontendBackendCompatibilitySync — a backend test that asserts TARGET_EVAL_PARAMS matches the expected set. If someone adds a behavioral param, this test fails with a message directing them to update the frontend isCompatible() function.

Tests and Documentation

Backend Tests

  • Mapper (test_mappers.py): 4 tests — inner target population, model name hoisting when uniform, model name omission when divergent, null for non-composite targets.
  • Service (test_target_service.py): 3 tests — registry name resolution with mocked constructor, <2 names error, unknown name error.
  • Initializer (test_targets_initializer.py): 1 test — deduplication of identical targets in auto-grouping verifies that a duplicate target (same endpoint + model + key registered under a different name) is excluded from the round-robin group.
  • Drift guard (test_target_service.py): 1 test — TARGET_EVAL_PARAMS matches frontend isCompatible() fields.

Frontend Tests

  • TargetTable (TargetTable.test.tsx): 2 tests — expand button renders only for RoundRobinTarget, click expands sub-rows with inner target details.
  • TargetBadge (TargetBadge.test.tsx): 2 tests — ×N count in display name, underlying_model_name preferred over model_name.
  • CreateTargetDialog (CreateTargetDialog.test.tsx): 2 tests — RoundRobinTarget shows target picker (hides endpoint fields), Create button disabled with <2 selections.

Manual Testing

  • Verified end-to-end behavior manually: target creation, table display, expand/collapse, badge tooltip, compatibility filtering, and dedup behavior (previously 5 inner targets reduced to 3 after dedup fix).

@romanlutz romanlutz self-assigned this Jun 5, 2026
weights: list[int] | None = params.get("weights") or None

# The constructor validates same-class, same-config, behavioral consistency, etc.
return RoundRobinTarget(targets=resolved_targets, weights=weights)
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 dedup logic you added to _auto_group_targets (skipping members with the same ComponentIdentifier.hash) should apply here too. Right now the user-driven flow happily accepts duplicates — easy to repro in the GUI:

  1. Pick RoundRobinTarget.
  2. Add azure_openai_gpt4o and openai_chat — both OpenAIChatTarget (gpt-4o-japan-nilfilter) pointing at the same endpoint.

The created RR's inner_targets come back with identical unique_names (OpenAIChatTarget::5bf9656e ×2), so the "round-robin" just hits the same target twice. The picker doesn't dedupe by identifier hash either, which makes this very easy to do by accident with the airt init (which often registers the same target under multiple aliases).

Cheapest fix: before constructing the RR, dedupe resolved_targets by target.get_identifier().hash and raise if < 2 distinct entries remain. Same shape as the seen_hashes block in the initializer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

implemented in the most recent commit. but, I think the better solution might be to not show identical targets when the first target is chosen (following the pattern of not allowing non-compatible targets to be selected). We would have to use identifier_hash though and expose it on TargetInstance.

style={{ width: '60px' }}
onChange={(_, data) => {
const w = parseInt(data.value, 10)
if (!isNaN(w) && w > 0) updateInnerTargetWeight(sel.registryName, w)
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.

Minor: parseInt(data.value, 10) silently truncates decimal input (2.5 becomes 2, 1e10 becomes 1) and there's no upper bound, so a typo like 99999999999 is accepted and forwarded to the backend. The HTML min="1" is also advisory only — 0 and negatives are filtered in JS, which is fine, but a user typing 0 sees the value visually revert with no feedback.

Adding step="1" + max="1000" (or whatever sane cap) and showing a small validation message when the parse fails would make the weight field behave the way the UI implies.

Comment thread frontend/src/components/Config/CreateTargetDialog.tsx
jsong468 and others added 3 commits June 5, 2026 11:58
Mirrors the dedup logic in TargetInitializer._auto_group_targets so the
GUI/API flow can't produce a round-robin that hits the same underlying
endpoint twice. Weights for deduped entries are dropped alongside them.
If fewer than 2 distinct targets remain after dedup, raise a clear error
listing the skipped duplicate names.

Addresses review comment on PR microsoft#1944.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…, fix isCompatible fallback

Addresses Roman's review comment on isCompatible() in CreateTargetDialog:

1. Hash-based dedup in the picker (Option A from review discussion).
   - Surfaces ComponentIdentifier.hash as a new optional identifier_hash field
     on the TargetInstance DTO.
   - target_object_to_instance now populates it; the existing recursive
     _build_inner_targets call propagates it to inner targets for free.
   - eligibleTargets in CreateTargetDialog filters out candidates whose
     identifier_hash matches any already-selected target's hash. Targets with
     null/undefined hash are NOT collapsed together (safe handling).
   - This plugs the last gap left by isCompatible: behavioral-param mismatches
     are already pre-filtered, but two registry entries that resolve to the
     same backend config (same hash) were still pickable and would hit the
     service-layer dedup error from the previous commit.

2. Fix isCompatible() fallback bug (Roman's specific repro).
   - Adds effectiveUnderlyingModel(t) helper using || (catches both null AND
     empty string, matching the backend's �alue is None or value == "" rule
     in RoundRobinTarget._resolve_param).
   - isCompatible() now compares effective underlying models so that, for
     example, picking azure_foundry_deepseek (DeepSeek-R1, underlying=None)
     no longer shows azure_foundry_mistral_large / google_gemini / ollama
     as compatible just because all four have underlying_model_name=None.

3. Surface RFC 7807 backend detail on failed creation.
   - Replaces the catch block's err.message with toApiError(err).detail so the
     dialog shows the actual validation message (e.g. "Behavioral parameter
     'underlying_model_name' differs...") instead of the generic axios
     "Request failed with status code 400".
   - Updates one pre-existing test ("non-Error exceptions") that expected a
     hard-coded "Failed to create target" string — toApiError now surfaces the
     thrown string verbatim, which is strictly more informative.

4. Extend the frontend/backend drift guard.
   - Adds test_target_eval_param_fallbacks_match_frontend asserting
     TARGET_EVAL_PARAM_FALLBACKS == {'underlying_model_name': 'model_name'}
     so future fallback additions surface a clear failure pointing at
     effectiveUnderlyingModel() in CreateTargetDialog.tsx.

Tests added:
- backend: test_target_eval_param_fallbacks_match_frontend
- backend: extended test_maps_target_with_identifier to cover identifier_hash
- frontend: filters duplicate-by-identifier-hash targets out of the picker
- frontend: applies underlying_model_name -> model_name fallback when filtering
- frontend: surfaces the backend error detail when target creation fails

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Roman's review comment #2: parseInt("2.5") silently truncates to 2, parseInt("1e10") returns 1, and 99999999999 was accepted with no upper bound. Typing 0 silently reverted.

Changes:

- Extract parseWeight + MAX_WEIGHT=1000 to weightValidation.ts

- Strict regex-based parser rejects empty, non-integers (2.5, 1e10, -3), values < 1, and values > MAX_WEIGHT

- Refactor SelectedInnerTarget to single source of truth (weightInput: string), avoiding silent revert

- Add step='1', min='1', max='1000', aria-invalid, aria-label to Input; render alert below row on invalid

- Disable Create when any weight is invalid; re-validate in handleSubmit to defend against Enter-key bypass

Tests: 9 parseWeight unit tests + 2 dialog integration tests (alert+disabled, end-to-end submit with parsed ints).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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