Skip to content

feat: route model groups across modalities - #837

Merged
seonghobae merged 2 commits into
feat/model-group-cost-aware-discoveryfrom
feat/multimodal-model-group-orchestration
Aug 25, 2026
Merged

feat: route model groups across modalities#837
seonghobae merged 2 commits into
feat/model-group-cost-aware-discoveryfrom
feat/multimodal-model-group-orchestration

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • discover the full OpenRouter catalog and preserve provider-declared input/output modalities
  • route operator-defined groups for text, image, video, speech, transcription, embeddings, rerank, and audio capabilities
  • add authenticated JSON/binary inference surfaces with measured group-member failover
  • document the evidence boundary: modality metadata is provider evidence; group equivalence remains operator-declared

Verification

  • pytest -q tests/test_api_contract.py tests/test_model_discovery.py tests/test_model_group.py tests/test_multimodal_model_group_http.py tests/test_embeddings_model_pool_http_honesty.py (58 passed)
  • full suite running on this exact head

Research and standards

  • ADR 0026 cites MMR-Bench and current official OpenRouter modality/endpoint contracts in APA 7th style
  • no transient model identifier or inferred equivalence is built in

Open in Devin Review

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d1520213-389a-4f44-b0fc-2bf65838f7ef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines +189 to +191
capabilities = tuple(
dict.fromkeys(_CAPABILITY_NAMES.get(value, value) for value in (*source.capabilities, *inputs, *outputs))
)

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.

🔴 Input-only vision models treated as image generators

The capabilities tuple merges input modalities alongside outputs, so a vision model that only accepts image input gets the plain image capability tag. Selection matches on that tag, so /v1/images/generations requests can route to models that cannot generate images.

Prompt for agents
In _parse_openai_compatible (contextual_orchestrator/model_discovery.py), the capabilities tuple is derived from (*source.capabilities, *inputs, *outputs), which folds provider-declared INPUT modalities into the generation capability set. Because agent_from_discovered copies these capabilities into the agent's plain tags, and _capability_agents in orchestrator.py selects members with `capability in agent.tags`, an image-input (vision) model ends up matching the 'image' generation capability. ADR 0026 explicitly promises that provider-declared direction is retained so 'an input-capable vision model is not mistaken for an image generator'. The direction-tagged form (input:<modality>/output:<modality>) is preserved, but capability matching ignores it. Decide the correct semantics for each capability (image/video/speech should match on OUTPUT modality; transcription/audio-in cases may need input) and make capability derivation and/or the _capability_agents selection direction-aware so input-only modalities do not grant a generation capability.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +5039 to +5052
if path in capability_routes:
_validate_capability_request(path, body)
capability, endpoint, binary = capability_routes[path]
result = self._run(
lambda: orchestrator.proxy_capability(
body, capability=capability, endpoint=endpoint, binary=binary
)
)
if binary:
raw, content_type = result
self._send_bytes(raw, content_type)
else:
self._send(result)
return

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.

🟡 Unavailable capability returns 500 not 503

When no enabled member supports the requested capability, proxy_capability raises RuntimeError, which this dispatch lets fall through to the generic handler as a 500. The API contract documents 503 for these endpoints, and the embeddings path maps the same failure to 503.

Prompt for agents
The capability route dispatch in server.py do_POST calls orchestrator.proxy_capability, which (via _capability_agents) raises RuntimeError when no enabled member supports the capability or group, and also raises RuntimeError when all candidates fail. These propagate to do_POST's generic `except Exception` handler and produce a 500 internal_error. api_contract.py declares 503 'No capable model group member is available' for these endpoints, and _validate_embeddings_model already maps the same select_capability_agent failure to a 503. Wrap the proxy_capability call (or catch RuntimeError from _capability_agents) and raise RequestError(503, ...) so the documented status is returned when no capable member exists.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +2806 to +2810
requested_group = (
canonical_group_name(model_name)
if model_name is not None and model_name not in exact_models
else None
)

@devin-ai-integration devin-ai-integration Bot Aug 25, 2026

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.

📝 Info: Unknown single-word model gives confusing 400

_capability_agents calls canonical_group_name(model_name) eagerly for any model not served exactly. A single-token unknown model makes it raise ValueError, surfaced to the client as a group-naming message rather than a clear 'model not found'. _require_pool_model guards this call in try/except; this path does not.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1215 to +1232
def proxy_send_bytes(
self, agent: ModelAgent, endpoint: str, payload: dict[str, Any]
) -> tuple[bytes, str]:
"""Passthrough a provider response whose body is binary media."""
if agent.base_url.startswith("mock://"):
return b"mock audio", "audio/mpeg"
api_key = _provider_credential(agent) # pragma: no cover
headers = {"content-type": "application/json"} # pragma: no cover
if api_key: # pragma: no cover
headers["authorization"] = f"{agent.auth_scheme} {api_key}"
request = urllib.request.Request( # pragma: no cover
self._provider_url(agent, f"/{endpoint.lstrip('/')}"),
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
with self._open_provider(request, self._validate_provider(agent)) as response: # pragma: no cover
return response.read(), response.headers.get_content_type()

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.

📝 Info: Binary media path has no transient retry

proxy_send_bytes issues one provider request with no retry, unlike proxy_send which uses _send_raw_with_retry. Binary endpoints get no per-request transient retry; only cross-member failover in proxy_capability remains.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…overy' into feat/multimodal-model-group-orchestration
@seonghobae
seonghobae merged commit b353e6a into feat/model-group-cost-aware-discovery Aug 25, 2026
1 of 2 checks passed
@seonghobae
seonghobae deleted the feat/multimodal-model-group-orchestration branch August 25, 2026 01:30

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +187 to +188
inputs = tuple(value for value in architecture.get("input_modalities", ()) if isinstance(value, str))
outputs = tuple(value for value in architecture.get("output_modalities", ()) if isinstance(value, str))

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.

🔴 Null modality field crashes entire model discovery

A provider row whose architecture.input_modalities or output_modalities is JSON null makes the generator iterate None and raise TypeError (the () default applies only when the key is absent). discover_provider_models catches only URLError/TimeoutError/ValueError and discover_all_models catches only ProviderDiscoveryError, so one malformed row aborts discovery for every provider.

Suggested change
inputs = tuple(value for value in architecture.get("input_modalities", ()) if isinstance(value, str))
outputs = tuple(value for value in architecture.get("output_modalities", ()) if isinstance(value, str))
raw_inputs = architecture.get("input_modalities")
inputs = tuple(v for v in raw_inputs if isinstance(v, str)) if isinstance(raw_inputs, list) else ()
raw_outputs = architecture.get("output_modalities")
outputs = tuple(v for v in raw_outputs if isinstance(v, str)) if isinstance(raw_outputs, list) else ()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

seonghobae added a commit that referenced this pull request Aug 26, 2026
* feat(routing): add operator-managed model groups

* fix(api): enforce model group create semantics

* fix(routing): preserve eligibility and REST contracts

* docs(groups): remove ephemeral model example

* fix(groups): route advertised aliases end to end

* ci: hourly OpenCode maintenance agent routed through this gateway (#835)

* ci: add hourly OpenCode maintenance agent routed through this gateway

The scheduled job boots the contextual-orchestrator gateway with the five
org provider secrets seeded into the process-local KV registry (bootstrap
transport only), auto-discovers chat-capable models, assigns the ox-alpha
measured-routing group across OpenRouter + OpenCode Zen aliases, then runs
the pinned OpenCode CLI pointed at http://127.0.0.1:8000/v1 with model
'ox-alpha' so the agent's own traffic exercises group routing.

The agent works the PR queue (review -> fix -> recheck -> merge),
root-causes failing checks, and advances
docs/product-technical-gap-baseline.md when the PR queue is empty.
COPILOT_GITHUB_TOKEN is not used; the existing review-agent key scheme is
untouched.

* fix(ci): remove ephemeral model binding

* fix(ci): install gateway and grant branch writes

* fix(groups): preserve internal dispatch default

* fix: isolate model group routing evidence

* fix: preserve conduct semantics for model groups

* ci: minimize hourly loop permissions and installs

* fix: measure streamed group routing

* feat: route model groups across modalities (#837)

* feat: normalize and edit model groups (#838)

* fix: preserve capability routing contracts

* release: v0.2.0 — model groups, cost-aware discovery, changelog baseline

Bump 0.1.0 -> 0.2.0 and add the canonical Keep-a-Changelog file with the
0.1.0 baseline and the 0.2.0 additions (operator-managed model groups,
measured group routing, OpenCode Zen discovery + free-tier
classification, Strix B105 root-cause remediation).

* fix(discovery): tolerate null modality arrays

* docs: specify model group product and technical contracts

* release: align v0.2.0 changelog and lock metadata

* feat: stream orchestrated reasoning summaries

* fix: keep free reasoning streams fail closed

* fix: preserve free routing evidence and analytics

* fix: distinguish failed Responses streams in analytics

* fix: pin structured free judge to selected agent

* fix: lock container dependencies and virtual capabilities

* fix: keep free passthrough on zero-cost models

* fix: prune removed routing measurements

* refactor: remove unreachable responses passthrough branch

* fix: route virtual models across media capabilities

* fix: retain reset candidate routing rows

* fix: preserve Responses instructions in workflows

* test: align model-group missing-member code with canonical agent_not_found

#831 unified worker-agent not-found errors on agent_not_found; the
model-group CRUD contract now asserts that same canonical code.

* fix(api): model-group creation returns canonical agent_not_found for unknown members

* ci: route hourly OpenCode loop through auto

* fix: reject unsupported orchestrated structured output

* docs: correct Responses stream options error

* fix: retrieve URL-encoded model identifiers

* fix: measure free capability and failover routing

* fix: resolve model group review findings

* fix: close model group integration gaps

* fix: align provider inventory and session cache scope

* fix: harden compose secrets and repeated reasoning summaries

* fix: contain binary response disconnects

* fix: ground Zen free discovery in structured costs

* fix: keep group judge within allowed members

* fix: preserve free catalog evidence across reloads

* docs: assign unique model-group ADR number

* fix: preserve catalog capability evidence

* fix: preserve declared Bytez endpoint capabilities

* fix: validate batch model identity at ingress

* fix(api): normalize missing model group errors

* docs(prd): align product bets with model groups

* fix(discovery): filter chat-only Bytez transports

* docs(adr): reserve model-group decision identifier

* feat: replace routing heuristics with measured evidence ledgers (#847)

* feat: replace routing heuristics with measured evidence ledgers

Remove DOMAIN_HINTS/COMPLEX_HINTS keyword tables; route via eligibility
contracts, declaration priority/capability fit/cosine affinity over
operator-declared metadata, and measured intra-group quality then EWMA
tokens-per-second. Add structured fail-closed triage gas with content-hash
verdict caching and real-time fast-mlsirm judging on direct routes that
feeds a Beta-Bernoulli quality ledger with in-budget failover.

ADR 0027 + doctoring APA 7 references (Jacobson 1988; Gelman et al. 2013;
Karpukhin et al. 2020; Ong et al. 2024; Chen et al. 2023; Zheng et al.
2023; Jeon et al. 2021). Gap baseline added at
docs/product-technical-gap-baseline.md.

* fix: keep routing evidence units and capability boundaries honest

* fix(admin): tolerate unavailable model-group state

* docs(loop): require PRD and measured web capacity

* fix(admin): remove retired policy hint metric

* fix(routing): validate evidence before mutation

* fix(discovery): remove model-name free inference guidance

* fix: complete #834 model-group persistence on the normalized agent-pool schema

- model_group/model_group_member relations compose with main's normalized
  agent_pool (no JSON shadow); save() maintains membership, load_all()
  restores group_name via join.
- Legacy payload promotion reads agent_pool_legacy_payloads during the
  migration window and drops it after promoting group names.
- DB-naming gate now extracts SQL from AST string constants so prose in
  comments can no longer produce false identifier violations.
- Batch runner signature aligned (messages, mode, model) with the merged
  LocalBatchBackend; stream-route fake accepts the merged caller kwargs.

* test: case-sensitive DDL pattern so prose cannot fake identifiers

* docs: refresh protected-main gap evidence

* docs: record exact provider regression suite

* docs: correct exact provider head

* fix(admin): make guidance customer-actionable

* fix(admin): remove internal terms from customer copy

* docs(ui): record customer-copy screenshot audit

* test(ci): lock hourly orchestrator loop contract

* fix: replace synthetic admin status with truthful empty states

* docs: refresh exact-head remediation baseline

* fix(stream): cancel orchestration after Responses disconnect

* test: align boundary contracts with current routing

* docs: complete public boundary docstrings

* docs: refresh exact-head product gap queue

* test: enforce complete public docstrings

* ci: enforce protected exact-head merge loop
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.

1 participant