Skip to content

feat(routes): provider routes — provider-level pools with ordered/round-robin/weighted selection #1132

Closed

Description

@dezren39

Drew's directive (2026-10-31)

make synthetic providers a concept which allow ordered round-robin weighted same as models except provider level

The concept

Model routes compose models: a pool (e.g. terra-plan) holds provider/model leaves and applies a selection mode (ordered / round-robin / weighted). Route expansion passes no model context into a nested route, so provider-level composition is impossible today. Tonight proved it: the GPT account pair had to be instantiated as three separate pools — openai-plan-terra, openai-plan-sol, openai-plan-luna in the laptop config — each an ordered [openai.alternate/<model>, openai/<model>] pair, because the account order is a property of the providers, not of any one model.

A synthetic provider is a virtual provider defined by a selection over concrete providers, with the same selection modes as model routes — ordered, round-robin (including the tiered weights semantics of #1122), and weighted. A model route then uses <synthetic-provider>/<model> as a leaf, and resolution picks the member provider per the synthetic provider's selection, yielding a concrete provider/model call.

Canonical first instance: synthetic provider openai-plan = ordered [openai.alternate, openai] — one definition serving every GPT model.

Design answers

Settled from the codebase's actual grammar (packages/core/src/config/plugin/model-routes.ts, packages/core/src/model-route.ts); the implementation follows these.

1. Config grammar. Declared in a new top-level block experimental.synthetic_providers, a record keyed by synthetic provider id (same id pattern as route ids). Each entry: providers (the member provider ids, in configured order), selection (ordered default, round-robin, weighted), and weights (keyed by member provider id; unset weighs 1). A route references a synthetic provider exactly like a concrete one — openai-plan/gpt-6.1-terra, string or object target form, #variant included — because the reference site is what makes the concept useful. That means synthetic ids share the provider-id namespace at reference sites, so collisions are rejected at config load: an id that names a concrete provider in the catalog, the routes provider (opencode-route), or a configured route id is dropped, loudly (a warning naming the id and the reason — never silently, cf. #734). Concrete providers always win a reference.

2. Member eligibility per dispatch. Mirrors the leaf rule exactly. A member contributes a leaf for a requested model only when its catalog lists the model (glob patterns match per member) and it can produce the requested variant — natively, through the target's remap table, or as a suffixed catalog id (#985). Otherwise the member is skipped for that dispatch; it is never an error. A synthetic target whose members contribute no leaf for the model attaches nothing, like a glob that matches nothing.

3. Health interaction. Resolution produces concrete provider/model leaves, and every health mechanism is keyed on concrete targets — per-target and per-credential cooldowns and the bench ladder, quota demotion, quota gates, rate-limit holds, budgets. A benched or walled member leaf is simply absent from the walk's ready set, and selection falls through per the synthetic group's own mode — including #1122's tier rule inside a round-robin synthetic (a demoted member sorts below the live tiers there). No synthetic-level health state exists; member readiness is judged by the same walk that judges nested route groups.

4. Nesting. No in v1. Members must be concrete provider ids; a member naming another synthetic provider (or the routes provider) is dropped at load with a warning. Expansion stays one level deep, like the door-set target (#637). Revisit only when a real composition need appears.

5. Decision records. Expansion gives the synthetic group its own node, marked with the synthetic id. The walk records a decision for that node — route_id is the synthetic id, candidates are the concrete member leaves, chosen is the member that served — and the parent decision names the group by its synthetic reference (openai-plan/gpt-6.1-terra). A walk stays explainable end to end, the way #1122's tiers detail made tiered choices explainable.

6. The route proxy door. The proxy in developing-today/code (bin/opencode-route-proxy.mjs, OpenClaw's door) resolves routes itself from the same config file — its own expand/rotate/weightedOrder, its own providerBaseURL and credential-store reads. It does not share the fork's expansion or selection code. Synthetic providers are therefore v1 server-side only: a route using a synthetic leaf will not resolve through the proxy until the proxy learns the concept. That follow-up belongs to the code repo and is not part of this work.

Implementation shape

  • Schema: a SyntheticProvider class and a SyntheticProviders record in packages/schema/src/config/model-routes.ts; experimental.synthetic_providers in packages/schema/src/config/experimental.ts.
  • Runtime: ModelRoute.SyntheticProvider (the validated form); the node tree gains an optional synthetic marker so decisions can name the group (answer 5).
  • Plugin: a configuredSynthetics reader (validation + loud drops, exported like configuredWindows) threaded through expand() and retargetDefinition() as an optional parameter, like windows. Expansion treats a synthetic target as a per-model group node — one group per matched model id, so a glob model pattern fans out per model and a group never mixes models — reusing the existing leaf machinery (variant producibility, dedup, ceilings, budgets, cache formats) verbatim. The group's selection and weights are honored when the referencing route uses rules, mirroring how the referencing route's own selection is gated by its autonomy; a fixed route sees the group as ordered with unit weights.

Acceptance criteria

  • A config declaring openai-plan once can serve openai-plan/<model> leaves in any number of routes, for every model both members offer.
  • All three selection modes behave per their model-route semantics at provider level, including tiered round-robin after feat(routes): round-robin weights as priority tiers — rotate within a tier, fall through when it is exhausted #1122.
  • A member that does not offer the model or the variant is skipped for that dispatch; a benched or demoted member falls through (last-resort rules unchanged).
  • A synthetic id colliding with a concrete provider, the routes provider, or a route id is rejected at load with a warning; the concrete provider keeps the reference.
  • Decision records show the synthetic resolution (openai-plan → the member that served).
  • Tests cover: ordered synthetic (first ready member; fall-through on failure/bench), round-robin synthetic (even rotation; tiered weights), weighted synthetic, member skipped for a missing model/variant, benched member excluded with fall-through, a synthetic leaf inside a model route resolved end to end, collision rejection, and the decision record.

Activity

  1. added
    source/humanFiled directly by a human
    priority/normalOrdinary priority (the default)
    effort/lLarge: multi-day or open-ended; expect staged check-ins
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 1, 2026
  2. dezren39 commented on Nov 1, 2026

    @dezren39
    Author

    Intake review

    Still live: filed this run (2026-11-01T02:07Z); no merged PR references it. The body already carries a full design (six settled answers) and an acceptance criteria section — enrichment here is labels only.

    Related: #1122 (tiered round-robin weights this builds on), #985 (suffixed catalog ids), #734 (loud-drop precedent), #637 (door-set target).

    Notes / risks: v1 is server-side only by design — the code-repo route proxy will not resolve synthetic leaves until it learns the concept (recorded in the issue as a follow-up belonging to developing-today/code, not part of this work). Collision handling (concrete provider wins, loud warning) is the safety-critical piece.

    Suggested approach: follow the implementation shape in the body — schema first, then the plugin reader threaded through expand()/retargetDefinition(), reusing leaf machinery verbatim.

    Labels applied: kind/enhancement, source/human, status/needs-triage, priority/normal, effort/l, meta/enriched.

  3. changed the title [-]feat(routes): synthetic providers — provider-level pools with ordered/round-robin/weighted selection[/-] [+]feat(routes): provider routes — provider-level pools with ordered/round-robin/weighted selection[/+] on Nov 1, 2026
  4. dezren39 commented on Nov 1, 2026

    @dezren39
    Author

    Renamed 2026-10-31: synthetic providers → provider routes.

    With Drew's approval (2026-10-31), the concept is renamed for symmetry with model_routes — routes, at provider level. Applied on PR #1134 in commit 6958dce851 ("refactor(core): rename synthetic providers to provider routes (#1132)"):

    • Config key: experimental.synthetic_providers → experimental.provider_routes
    • Schema: Config.ModelRoute.SyntheticProvider / .SyntheticProviders → ProviderRoute / ProviderRoutes
    • Loader: configuredSynthetics → configuredProviderRoutes
    • Route-group decision field: synthetic → providerRoute
    • Tests and the routing skill docs (routing-config.md) follow the new name throughout.

    Terminology only — no behavior change. The rename lands while the PR is unmerged, so no deployed config uses the old key and no migration is needed. The follow-up config collapse in the PR description (one openai-plan provider route replacing the three per-model pools) already uses the new key.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/lLarge: multi-day or open-ended; expect staged check-inskind/enhancementNew feature or requestmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)source/humanFiled directly by a humanstatus/needs-triageNot yet triaged; needs a first look

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions