Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions contextual_orchestrator/api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,20 @@
"/v1/embeddings": {
"post": {
"operationId": "create_embedding",
"summary": "Create embeddings for semantic input",
"summary": "Create embeddings with optional orchestrator-owned model selection",
"security": [{"inference_bearer_auth": []}],
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["model", "input"],
"required": ["input"],
"properties": {
"model": {"type": "string"},
"model": {
"type": "string",
"description": "Optional enabled embedding-capable pool model; omitted selects one.",
},
"input": {
"oneOf": [
{"type": "string"},
Expand All @@ -136,6 +139,7 @@
"responses": {
"200": {"description": "Embedding response"},
"400": {"description": "Invalid request"},
"503": {"description": "No enabled embedding-capable agent is available"},
},
}
},
Expand Down Expand Up @@ -587,9 +591,11 @@
"application/json": {
"schema": {
"type": "object",
"required": ["model"],
"properties": {
"model": {"type": "string"},
"model": {
"type": "string",
"description": "Optional enabled embedding-capable pool model; omitted selects one.",
},
"input": {
"oneOf": [
{"type": "string"},
Expand Down
17 changes: 17 additions & 0 deletions contextual_orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,7 @@ class TaskOrchestrator:
"worker": ("coding", "implementation", "reasoning"),
"verifier": ("verification", "security", "review", "debugging"),
"synthesizer": ("writing", "reasoning", "planning"),
"embedding": ("embedding",),
}
DOMAIN_HINTS = {
"coding": ("code", "bug", "debug", "implement", "repository", "test", "코드", "구현"),
Expand Down Expand Up @@ -2760,6 +2761,22 @@ def _select_agent(self, text: str, role: str) -> ModelAgent:
raise RuntimeError(f"no eligible agent available for role={role}")
return selected

def select_capability_agent(self, capability: str) -> ModelAgent:
"""Select an enabled agent carrying an explicit capability tag."""
capability = capability.strip().lower()
if not capability:
raise ValueError("capability must be a non-empty string")
ranked = [
agent
for agent in self._ranked_agents("", capability)
if not agent.disabled
and capability in agent.tags
and capability not in agent.provider_exclusions
]
if not ranked:
raise RuntimeError(f"no enabled agent available for capability={capability}")
return ranked[0]

def _invoke(
self, primary: ModelAgent, messages: list[ChatMessage], *, text: str, role: str
) -> tuple[str, str, dict[str, Any] | None]:
Expand Down
45 changes: 28 additions & 17 deletions contextual_orchestrator/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1826,7 +1826,9 @@ def _validate_mode(mode: Any) -> str:



def _require_pool_model(orchestrator: Any, model_name: str) -> None:
def _require_pool_model(
orchestrator: Any, model_name: str, *, required_capability: str | None = None
) -> None:
"""Fail closed when ``model_name`` is not served by any enabled agent.

OpenAI clients treat ``model`` as the deployment they paid for. Silently
Expand All @@ -1838,7 +1840,9 @@ def _require_pool_model(orchestrator: Any, model_name: str) -> None:
for agent in agents:
if getattr(agent, "disabled", False):
continue
if getattr(agent, "model", None) == model_name:
if getattr(agent, "model", None) == model_name and (
required_capability is None or required_capability in getattr(agent, "tags", ())
):
return
raise RequestError(
400,
Expand Down Expand Up @@ -4323,12 +4327,27 @@ def _validate_batch_embeddings_endpoint(body: dict[str, Any]) -> str | None:
return value


def _validate_embeddings_model(body: dict[str, Any]) -> str:
"""OpenAI embeddings ``model`` — required non-empty string ≤256 chars.
def _validate_embeddings_model(body: dict[str, Any], orchestrator: Any | None = None) -> str:
"""Validate or auto-select an OpenAI embeddings model.

Strip + write back (parity with chat/Completions/Responses) so padded
form/JS model names bind to the pool id on every surface.
form/JS model names bind to the pool id on every surface. An omitted model
is resolved by the orchestrator's explicit ``embedding`` capability pool;
no consumer-side sentinel model is accepted.
"""
if "model" not in body:
if orchestrator is None:
raise RequestError(400, "invalid_model", "model is required outside an orchestrator request")
try:
model = orchestrator.select_capability_agent("embedding").model
except (RuntimeError, ValueError) as exc:
raise RequestError(
503,
"embedding_unavailable",
"no enabled embedding-capable agent is available",
) from exc
body["model"] = model
return model
model = body.get("model")
if model is None:
raise RequestError(400, "invalid_model", "model is required")
Expand Down Expand Up @@ -5370,10 +5389,10 @@ def do_POST(self) -> None: # noqa: N802
# synchronously) and frames an OpenAI-shaped response so
# SDKs that call /v1/embeddings work without the batch path.
_reject_unknown_keys(body, ALLOWED_EMBEDDINGS_KEYS)
model_name = _validate_embeddings_model(body)
model_name = _validate_embeddings_model(body, orchestrator)
# Same pool honesty as chat/Completions: do not silently serve
# a different embedding deployment than the client requested.
_require_pool_model(orchestrator, model_name)
_require_pool_model(orchestrator, model_name, required_capability="embedding")
encoding_format = _validate_embeddings_encoding_format(body)
_validate_embeddings_dimensions(body)
end_user_id = _validate_completions_user(body)
Expand Down Expand Up @@ -5459,16 +5478,8 @@ def do_POST(self) -> None: # noqa: N802
if path == "/v1/batch/embeddings":
_reject_unknown_keys(body, ALLOWED_EMBEDDINGS_BATCH_KEYS)
inputs = _validate_embeddings_inputs(body)
# Require model — silent default to contextual-orchestrator was an
# honesty gap for naruon/batch clients that omit the field.
if "model" not in body:
raise RequestError(
400,
"invalid_model",
"model is required on /v1/batch/embeddings",
)
model_name = _validate_embeddings_model(body)
_require_pool_model(orchestrator, model_name)
model_name = _validate_embeddings_model(body, orchestrator)
_require_pool_model(orchestrator, model_name, required_capability="embedding")
_validate_embeddings_encoding_format(body)
_validate_embeddings_dimensions(body)
# OpenAI ``user`` end-user id — same fail-closed shape as sync embeddings.
Expand Down
103 changes: 103 additions & 0 deletions docs/planning/adrs/0015-auto-embedding-model-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
id: "0015"
title: "Orchestrator-owned automatic embedding model selection"
status: proposed
proposed_date: "2026-08-20"
deciders:
- "repository maintainer"
consulted:
- "contextual-orchestrator gateway runtime"
- "downstream embedding consumers"
informed:
- "downstream consumers (naruon, LineageWeave)"
affected_components:
- "contextual_orchestrator/orchestrator.py"
- "contextual_orchestrator/server.py"
- "contextual_orchestrator/api_contract.py"
- "contextual_orchestrator/batch_routing.py"
- "tests/test_embeddings_model_pool_http_honesty.py"
effort: S
supersedes: null
superseded-by: null
related:
- path: "docs/planning/adrs/0001-fail-closed-model-judgment.md"
relation: constrains
- path: "docs/planning/adrs/0002-explicit-local-mlx-evaluation.md"
relation: follows
---

# ADR 0015: Orchestrator-owned automatic embedding model selection

## Context

Consumers currently have to send a model name to the embeddings endpoints. A
consumer that already delegates model selection to contextual-orchestrator
must then invent a sentinel model name or maintain provider-specific
configuration. That contradicts the gateway-owned model policy and makes the
OpenAI-compatible contract less useful for downstream services.

Embedding agents are already represented in the orchestrator candidate pool by
the explicit `embedding` capability tag. The selection must therefore reuse
the existing ranked-agent policy rather than add a provider order, model-name
guess, or consumer-side fallback.

## Decision

1. `/v1/embeddings` and `/v1/batch/embeddings` accept an omitted `model`.
2. When omitted, the gateway selects the highest-ranked enabled agent carrying
the `embedding` capability. Ranking continues to use the existing priority
and capability policy; disabled agents and provider exclusions are ignored.
3. An explicitly supplied model remains supported only when it matches an
enabled embedding-capable agent. Unknown, disabled, or non-embedding models
fail closed with the existing invalid-model contract.
4. If no enabled embedding-capable agent exists for an omitted model, the
gateway returns `503 embedding_unavailable`; it never invents a model or
produces a heuristic vector as a provider substitute.
5. The resolved model is carried into internal batch requests, provider JSONL,
response metadata, and cost attribution so the selected deployment remains
deterministic and auditable. The standalone in-process backend remains a
local test/development path; a configured provider path uses its injected
embeddings backend and the resolved model.

## Contract and acceptance evidence

The OpenAPI contract marks `model` optional and documents the unavailable
response. Loopback HTTP tests cover omitted-model selection for sync and batch
requests, explicit pool validation, and the no-capability failure. Provider
backend contract tests must preserve the resolved model in every serialized
embedding request before this ADR moves from proposed to accepted.

## Consequences

LineageWeave, naruon, and other consumers can omit provider model selectors
while retaining pool validation, provider routing, and cost attribution.
Explicit OpenAI-compatible model requests remain backward compatible. The
gateway still exposes a clear distinction between local standalone evidence
and configured-provider evidence; local heuristic vectors are not production
provider evidence.

## Research grounding

The selection is a capability-constrained routing decision, not a semantic
quality judgment. It reuses the repository's vendored routing literature:

* Chen, L., Zaharia, M., & Zou, J. (2023). FrugalGPT: How to use large
language models while reducing cost and improving performance. *arXiv*.
https://arxiv.org/abs/2305.05176
* Ong, I., Almahairi, A., Wu, V., Chiang, W.-L., Wu, T., Gonzalez, J. E.,
Kadous, M. W., & Stoica, I. (2024). RouteLLM: Learning to route LLMs with
preference data. *arXiv*. https://arxiv.org/abs/2406.18665
* Ding, D., Mallick, A., Wang, C., Sim, R., Mukherjee, S., Rühle, V.,
Lakshmanan, L. V. S., & Awadallah, A. H. (2024). Hybrid LLM:
Cost-efficient and quality-aware query routing. *International Conference
on Learning Representations*. https://arxiv.org/abs/2404.14618

These papers ground cost-aware and capability-aware routing decisions; they do
not provide evidence that one embedding model is universally higher quality.
No such unsupported quality claim is made by this ADR.

## More information

* docs/papers/README.md
* docs/rest_api_design.md
* docs/planning/adrs/0001-fail-closed-model-judgment.md
16 changes: 16 additions & 0 deletions tests/test_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,23 @@ def test_openapi_documents_compatibility_front_door() -> None:
]


def test_openapi_documents_orchestrator_owned_embedding_model_selection() -> None:
embeddings_schema = OPENAPI_SPEC["paths"]["/v1/embeddings"]["post"]["requestBody"]["content"][
"application/json"
]["schema"]
batch_schema = OPENAPI_SPEC["paths"]["/v1/batch/embeddings"]["post"]["requestBody"]["content"][
"application/json"
]["schema"]

assert embeddings_schema["required"] == ["input"]
assert "model" not in batch_schema.get("required", [])
assert "Optional enabled embedding-capable pool model" in embeddings_schema["properties"]["model"][
"description"
]


if __name__ == "__main__": # pragma: no cover
test_rest_resource_paths_use_two_word_snake_case()
test_openapi_uses_resource_oriented_operation_ids()
test_openapi_documents_orchestrator_owned_embedding_model_selection()
print("ok")
10 changes: 5 additions & 5 deletions tests/test_embeddings_encoding_format_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@
from __future__ import annotations

import json
import sys
import threading
import urllib.error
import urllib.request
from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from contextual_orchestrator import ModelAgent, TaskOrchestrator # noqa: E402
from contextual_orchestrator.server import SecurityConfig, build_server # noqa: E402
from contextual_orchestrator import ModelAgent, TaskOrchestrator
from contextual_orchestrator.server import SecurityConfig, build_server

_TEST_AUTH_TOKEN = "embeddings_encoding_format_http_honesty_token" # noqa: S105
_TEST_AUTH_TOKEN = "embeddings_encoding_format_http_honesty_token"


def build() -> TaskOrchestrator:
return TaskOrchestrator(
[ModelAgent("general_agent", "mock-planner", tags=("reasoning", "writing"))]
[ModelAgent("general_agent", "mock-planner", tags=("reasoning", "writing", "embedding"))]
)


Expand Down
Loading