From 4a5cc7a7cd379d97c37c7caa4130e55b18363f9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:02:43 +0900 Subject: [PATCH 1/9] fix: negotiate unsupported temperature capability --- contextual_orchestrator/orchestrator.py | 64 +++++++++++++++++-- ...ptional-sampling-capability-negotiation.md | 39 +++++++++++ tests/test_provider_integration.py | 54 +++++++++++++++- 3 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 docs/planning/adrs/0012-optional-sampling-capability-negotiation.md diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index d2fc62579..d3495a0f4 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -61,6 +61,33 @@ def _safe_provider_probe_error_type(exc: Exception) -> str: return name if name in _SAFE_PROVIDER_PROBE_ERROR_TYPES else "UnknownError" +def _temperature_capability_rejection(exc: Exception) -> bool: + """Recognize only explicit provider rejection of the optional temperature field.""" + if not isinstance(exc, urllib.error.HTTPError) or exc.code not in {400, 422}: + return False + try: + body = exc.read() + except (OSError, ValueError): + body = b"" + if isinstance(body, bytes): + detail = body.decode("utf-8", errors="replace") + else: + detail = str(body) + detail = f"{exc} {detail}".lower() + return "temperature" in detail and any( + marker in detail + for marker in ( + "unsupported", + "not supported", + "does not support", + "unknown parameter", + "unknown field", + "unrecognized", + "not allowed", + ) + ) + + def _validate_provider_probe_timeout(timeout: float) -> float: """Validate the finite, bounded timeout used by explicit readiness probes.""" if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): @@ -1030,13 +1057,20 @@ def _send( headers=headers, method="POST", ) - opened = ( - self._open_provider(request, destination) - if timeout is None - else self._open_provider(request, destination, timeout=timeout) - ) - with opened as response: - data = json.loads(response.read().decode("utf-8")) + try: + opened = ( + self._open_provider(request, destination) + if timeout is None + else self._open_provider(request, destination, timeout=timeout) + ) + with opened as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + if "temperature" not in payload or not _temperature_capability_rejection(exc): + raise + retry_payload = dict(payload) + retry_payload.pop("temperature", None) + return self._send(agent, retry_payload, destination, timeout=timeout) usage = data.get("usage") if isinstance(usage, dict): self._local.usage = usage @@ -1287,6 +1321,22 @@ def _send_raw_with_retry( for attempt in range(retry_limit + 1): try: return self._send_raw(agent, endpoint, payload, destination) + except urllib.error.HTTPError as exc: + if "temperature" in payload and _temperature_capability_rejection(exc): + retry_payload = dict(payload) + retry_payload.pop("temperature", None) + try: + return self._send_raw(agent, endpoint, retry_payload, destination) + except Exception as retry_error: # noqa: BLE001 - classify negotiated retry + last_error = retry_error + if attempt >= retry_limit or not is_transient_error(retry_error): + break + self._sleep(self._backoff_delay(attempt)) + continue + last_error = exc + if attempt >= retry_limit or not is_transient_error(exc): + break + self._sleep(self._backoff_delay(attempt)) except Exception as exc: # noqa: BLE001 - classify then decide last_error = exc if attempt >= retry_limit or not is_transient_error(exc): diff --git a/docs/planning/adrs/0012-optional-sampling-capability-negotiation.md b/docs/planning/adrs/0012-optional-sampling-capability-negotiation.md new file mode 100644 index 000000000..cc7642195 --- /dev/null +++ b/docs/planning/adrs/0012-optional-sampling-capability-negotiation.md @@ -0,0 +1,39 @@ +# ADR 0012: Optional sampling capability negotiation + +- Status: Accepted +- Date: 2026-08-20 + +## Context + +Some provider deployments reject the optional `temperature` request field even +when the value is valid for the public API contract. A provider response that +only reports an invalid value must not be silently changed into a different +request. The same transport boundary serves chat completions and raw Responses +passthrough, so the behavior must be endpoint-local and provider-neutral. + +## Decision + +When a provider returns HTTP 400 or 422 and the response evidence explicitly +identifies `temperature` as unsupported, the orchestrator retries once against +the same endpoint with only `temperature` removed. This negotiation is +available to both the normal chat transport and raw/Responses passthrough. + +All other 4xx responses, including invalid temperature values, remain +non-retryable. The orchestrator does not infer capability from a model name, +provider ordering, parameter count, or local benchmark, and it does not select +another model as a temperature fallback. + +## Consequences + +- GPT-5-family or otherwise restricted deployments can answer when the only + incompatibility is an optional sampling field. +- The original endpoint, model, authentication, and all other request fields + remain unchanged. +- The provider error body is consumed only for bounded capability + classification; it is not persisted or exposed as a credential-bearing log. + +## Verification + +`tests/test_provider_integration.py` covers successful chat negotiation, +invalid-value non-negotiation, and raw Responses negotiation over a real local +HTTP server. diff --git a/tests/test_provider_integration.py b/tests/test_provider_integration.py index 49238f17c..5edaee425 100644 --- a/tests/test_provider_integration.py +++ b/tests/test_provider_integration.py @@ -34,12 +34,14 @@ class _FakeProvider: def __init__(self, responses: list[tuple[int, dict]]) -> None: self.request_count = 0 + self.requests: list[dict] = [] outer = self class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: # noqa: N802 length = int(self.headers.get("content-length", 0)) - self.rfile.read(length) + raw_request = self.rfile.read(length) + outer.requests.append(json.loads(raw_request.decode("utf-8"))) index = min(outer.request_count, len(responses) - 1) outer.request_count += 1 status, body = responses[index] @@ -112,8 +114,54 @@ def test_permanent_4xx_is_not_retried_over_http() -> None: client._send_with_retry(_agent(provider.base_url), {"model": "gpt-x"}) except RuntimeError: raised = True - assert raised - assert provider.request_count == 1 # 400 is a real HTTPError classified permanent: one attempt + assert raised + assert provider.request_count == 1 # 400 is a real HTTPError classified permanent: one attempt + + +def test_unsupported_temperature_is_negotiated_on_the_same_chat_endpoint() -> None: + with _FakeProvider([ + (422, {"error": {"message": "temperature is not supported for this deployment"}}), + (200, _completion("negotiated")), + ]) as provider: + client = ModelClient(max_retries=0) + result = client._send_with_retry( + _agent(provider.base_url), {"model": "gpt-x", "temperature": 0.2} + ) + assert result == "negotiated" + assert provider.request_count == 2 + assert provider.requests[0]["temperature"] == 0.2 + assert "temperature" not in provider.requests[1] + + +def test_invalid_temperature_is_not_treated_as_capability_negotiation() -> None: + with _FakeProvider([(400, {"error": {"message": "invalid temperature value"}})]) as provider: + client = ModelClient(max_retries=3, retry_backoff=0.0) + raised = False + try: + client._send_with_retry( + _agent(provider.base_url), {"model": "gpt-x", "temperature": 2.5} + ) + except RuntimeError: + raised = True + assert raised + assert provider.request_count == 1 + + +def test_unsupported_temperature_is_negotiated_for_raw_responses_transport() -> None: + response = {"id": "response-1", "output": [{"type": "message"}]} + with _FakeProvider([ + (400, {"error": {"message": "unknown parameter: temperature"}}), + (200, response), + ]) as provider: + client = ModelClient(max_retries=0) + result = client._send_raw_with_retry( + _agent(provider.base_url), + "responses", + {"model": "gpt-x", "input": "hello", "temperature": 0.2}, + ) + assert result == response + assert provider.request_count == 2 + assert "temperature" not in provider.requests[1] def test_connection_error_is_transient_and_exhausts() -> None: From beb15a7d77d3d47b8de711928faf76ab5d4f7ef7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:26:55 -0700 Subject: [PATCH 2/9] test(protocol): preserve 4xx evidence and negotiated retry state --- ...perature_capability_negotiation_honesty.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_temperature_capability_negotiation_honesty.py diff --git a/tests/test_temperature_capability_negotiation_honesty.py b/tests/test_temperature_capability_negotiation_honesty.py new file mode 100644 index 000000000..bef32804a --- /dev/null +++ b/tests/test_temperature_capability_negotiation_honesty.py @@ -0,0 +1,112 @@ +"""Regressions for bounded, evidence-preserving temperature negotiation.""" + +from __future__ import annotations + +import io +import json +import socket +import urllib.error + +import pytest + +from contextual_orchestrator import ModelAgent +from contextual_orchestrator.orchestrator import ( + ModelClient, + _temperature_capability_rejection, +) + + +def _http_error(status: int, message: str) -> urllib.error.HTTPError: + body = json.dumps({"error": {"message": message}}).encode("utf-8") + return urllib.error.HTTPError( + "https://provider.example/v1/chat/completions", + status, + "provider error", + {}, + io.BytesIO(body), + ) + + +def test_invalid_temperature_range_is_not_negotiated_as_missing_capability() -> None: + """A bad caller value must remain a 4xx instead of being silently removed.""" + error = _http_error(400, "temperature is not allowed to be greater than 1") + + assert not _temperature_capability_rejection(error) + + +def test_non_negotiated_error_body_remains_available_to_the_caller() -> None: + """Capability inspection must not consume evidence from an unrelated 4xx.""" + expected = json.dumps( + {"error": {"message": "invalid temperature value"}} + ).encode("utf-8") + error = urllib.error.HTTPError( + "https://provider.example/v1/chat/completions", + 400, + "provider error", + {}, + io.BytesIO(expected), + ) + + assert not _temperature_capability_rejection(error) + assert error.read() == expected + + +class _Response: + """Minimal context-managed JSON response for transport tests.""" + + def __init__(self, payload: dict[str, object]) -> None: + self._body = json.dumps(payload).encode("utf-8") + + def __enter__(self) -> "_Response": + return self + + def __exit__(self, *_args: object) -> bool: + return False + + def read(self) -> bytes: + return self._body + + +def test_transient_retry_after_negotiation_keeps_temperature_omitted(monkeypatch) -> None: + """Once unsupported is proven, later transient retries must use the negotiated payload.""" + client = ModelClient(max_retries=1, retry_backoff=0.0) + agent = ModelAgent( + "provider_agent", + "restricted-model", + base_url="https://provider.example/v1", + ) + sent_payloads: list[dict[str, object]] = [] + + def open_provider(request, _destination=None, **_kwargs): + sent_payloads.append(json.loads(request.data.decode("utf-8"))) + if len(sent_payloads) == 1: + raise _http_error(422, "temperature is not supported for this deployment") + if len(sent_payloads) == 2: + raise _http_error(503, "temporarily unavailable") + return _Response( + { + "choices": [ + {"message": {"role": "assistant", "content": "recovered"}} + ] + } + ) + + monkeypatch.setattr(client, "_open_provider", open_provider) + monkeypatch.setattr(client, "_sleep", lambda _delay: None) + + result = client._send_with_retry( + agent, + {"model": agent.model, "messages": [], "temperature": 0.2}, + (socket.AF_INET, ("93.184.216.34", 443)), + ) + + assert result == "recovered" + assert ["temperature" in payload for payload in sent_payloads] == [ + True, + False, + False, + ] + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__])) From cf4a4501fa5057f89b21cad5033c5925755cd150 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:17:44 +0900 Subject: [PATCH 3/9] fix: preserve negotiated temperature capability --- contextual_orchestrator/orchestrator.py | 107 ++++++++++-------- ...perature_capability_negotiation_honesty.py | 11 ++ 2 files changed, 69 insertions(+), 49 deletions(-) diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index d3495a0f4..d5b34ae06 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -69,10 +69,11 @@ def _temperature_capability_rejection(exc: Exception) -> bool: body = exc.read() except (OSError, ValueError): body = b"" - if isinstance(body, bytes): - detail = body.decode("utf-8", errors="replace") - else: - detail = str(body) + body_bytes = body if isinstance(body, bytes) else str(body).encode("utf-8") + replay = io.BytesIO(body_bytes) + exc.fp = exc.file = replay + exc.read = replay.read + detail = body_bytes.decode("utf-8", errors="replace") detail = f"{exc} {detail}".lower() return "temperature" in detail and any( marker in detail @@ -83,7 +84,6 @@ def _temperature_capability_rejection(exc: Exception) -> bool: "unknown parameter", "unknown field", "unrecognized", - "not allowed", ) ) @@ -994,18 +994,34 @@ def _send_with_retry( """Call the provider, retrying transient failures with exponential backoff + jitter.""" last_error: Exception | None = None retry_limit = self._retry_limit(agent) + active_payload = payload + temperature_negotiated = False for attempt in range(retry_limit + 1): # pragma: no branch - retry limits are validated non-negative - try: - return ( - self._send(agent, payload, destination) - if timeout is None - else self._send(agent, payload, destination, timeout=timeout) - ) - except Exception as exc: # noqa: BLE001 - classify then decide - last_error = exc - if attempt >= retry_limit or not is_transient_error(exc): + while True: + try: + return ( + self._send(agent, active_payload, destination) + if timeout is None + else self._send(agent, active_payload, destination, timeout=timeout) + ) + except urllib.error.HTTPError as exc: + if ( + not temperature_negotiated + and "temperature" in active_payload + and _temperature_capability_rejection(exc) + ): + active_payload = dict(active_payload) + active_payload.pop("temperature", None) + temperature_negotiated = True + continue + last_error = exc break - self._sleep(self._backoff_delay(attempt)) + except Exception as exc: # noqa: BLE001 - classify then decide + last_error = exc + break + if attempt >= retry_limit or not is_transient_error(last_error): + break + self._sleep(self._backoff_delay(attempt)) detail = f": {last_error}" if last_error else "" raise RuntimeError(f"provider {agent.id} request failed{detail}") from last_error @@ -1057,20 +1073,13 @@ def _send( headers=headers, method="POST", ) - try: - opened = ( - self._open_provider(request, destination) - if timeout is None - else self._open_provider(request, destination, timeout=timeout) - ) - with opened as response: - data = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - if "temperature" not in payload or not _temperature_capability_rejection(exc): - raise - retry_payload = dict(payload) - retry_payload.pop("temperature", None) - return self._send(agent, retry_payload, destination, timeout=timeout) + opened = ( + self._open_provider(request, destination) + if timeout is None + else self._open_provider(request, destination, timeout=timeout) + ) + with opened as response: + data = json.loads(response.read().decode("utf-8")) usage = data.get("usage") if isinstance(usage, dict): self._local.usage = usage @@ -1318,30 +1327,30 @@ def _send_raw_with_retry( """Passthrough transport with the same transient-failure retry policy as _send.""" last_error: Exception | None = None retry_limit = self._retry_limit(agent) + active_payload = payload + temperature_negotiated = False for attempt in range(retry_limit + 1): - try: - return self._send_raw(agent, endpoint, payload, destination) - except urllib.error.HTTPError as exc: - if "temperature" in payload and _temperature_capability_rejection(exc): - retry_payload = dict(payload) - retry_payload.pop("temperature", None) - try: - return self._send_raw(agent, endpoint, retry_payload, destination) - except Exception as retry_error: # noqa: BLE001 - classify negotiated retry - last_error = retry_error - if attempt >= retry_limit or not is_transient_error(retry_error): - break - self._sleep(self._backoff_delay(attempt)) + while True: + try: + return self._send_raw(agent, endpoint, active_payload, destination) + except urllib.error.HTTPError as exc: + if ( + not temperature_negotiated + and "temperature" in active_payload + and _temperature_capability_rejection(exc) + ): + active_payload = dict(active_payload) + active_payload.pop("temperature", None) + temperature_negotiated = True continue - last_error = exc - if attempt >= retry_limit or not is_transient_error(exc): + last_error = exc break - self._sleep(self._backoff_delay(attempt)) - except Exception as exc: # noqa: BLE001 - classify then decide - last_error = exc - if attempt >= retry_limit or not is_transient_error(exc): + except Exception as exc: # noqa: BLE001 - classify then decide + last_error = exc break - self._sleep(self._backoff_delay(attempt)) + if attempt >= retry_limit or not is_transient_error(last_error): + break + self._sleep(self._backoff_delay(attempt)) raise RuntimeError(f"provider {agent.id} passthrough request failed") from last_error def _send_raw( diff --git a/tests/test_temperature_capability_negotiation_honesty.py b/tests/test_temperature_capability_negotiation_honesty.py index bef32804a..ddaf0ec50 100644 --- a/tests/test_temperature_capability_negotiation_honesty.py +++ b/tests/test_temperature_capability_negotiation_honesty.py @@ -34,6 +34,17 @@ def test_invalid_temperature_range_is_not_negotiated_as_missing_capability() -> assert not _temperature_capability_rejection(error) +def test_azure_default_only_temperature_is_negotiated() -> None: + """Azure's default-only diagnostic proves the optional field is unsupported.""" + error = _http_error( + 400, + "AzureException BadRequestError - Unsupported value: 'temperature' does not " + "support 0.2 with this model. Only the default (1) value is supported.", + ) + + assert _temperature_capability_rejection(error) + + def test_non_negotiated_error_body_remains_available_to_the_caller() -> None: """Capability inspection must not consume evidence from an unrelated 4xx.""" expected = json.dumps( From 995effd3ec8ae6784c4a15ae804124df11e74ff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:43:45 -0700 Subject: [PATCH 4/9] merge: integrate automatic embedding selection with temperature negotiation (#791) * feat: auto-select embedding agents * fix: align embedding selection API contract * test: cover embedding capability selection boundaries * test: declare embedding capability in embedding fixtures * style: normalize embedding HTTP fixture imports * docs: assign unique embedding selection ADR number --- contextual_orchestrator/api_contract.py | 16 ++- contextual_orchestrator/orchestrator.py | 17 +++ contextual_orchestrator/server.py | 45 +++++--- .../0015-auto-embedding-model-selection.md | 103 ++++++++++++++++++ tests/test_api_contract.py | 16 +++ ...embeddings_encoding_format_http_honesty.py | 10 +- ...test_embeddings_model_pool_http_honesty.py | 98 ++++++++++++++++- ...t_encoding_stream_logprobs_http_honesty.py | 10 +- ..._ledger_execution_identity_http_honesty.py | 10 +- tests/test_mode_casefold_http_honesty.py | 10 +- tests/test_openai_user_field_http_honesty.py | 10 +- ...oken_id_whole_float_coerce_http_honesty.py | 10 +- 12 files changed, 299 insertions(+), 56 deletions(-) create mode 100644 docs/planning/adrs/0015-auto-embedding-model-selection.md diff --git a/contextual_orchestrator/api_contract.py b/contextual_orchestrator/api_contract.py index 65e53691f..73723014b 100644 --- a/contextual_orchestrator/api_contract.py +++ b/contextual_orchestrator/api_contract.py @@ -111,7 +111,7 @@ "/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, @@ -119,9 +119,12 @@ "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"}, @@ -136,6 +139,7 @@ "responses": { "200": {"description": "Embedding response"}, "400": {"description": "Invalid request"}, + "503": {"description": "No enabled embedding-capable agent is available"}, }, } }, @@ -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"}, diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index d5b34ae06..40974a384 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -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", "코드", "구현"), @@ -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]: diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index 73f5f29f4..bbd681af6 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -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 @@ -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, @@ -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") @@ -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) @@ -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. diff --git a/docs/planning/adrs/0015-auto-embedding-model-selection.md b/docs/planning/adrs/0015-auto-embedding-model-selection.md new file mode 100644 index 000000000..95e1e1249 --- /dev/null +++ b/docs/planning/adrs/0015-auto-embedding-model-selection.md @@ -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 diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index f3640187b..cdaa0d4c2 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -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") diff --git a/tests/test_embeddings_encoding_format_http_honesty.py b/tests/test_embeddings_encoding_format_http_honesty.py index d32c0ebb8..d89694524 100644 --- a/tests/test_embeddings_encoding_format_http_honesty.py +++ b/tests/test_embeddings_encoding_format_http_honesty.py @@ -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"))] ) diff --git a/tests/test_embeddings_model_pool_http_honesty.py b/tests/test_embeddings_model_pool_http_honesty.py index 2466c455b..1abe352c6 100644 --- a/tests/test_embeddings_model_pool_http_honesty.py +++ b/tests/test_embeddings_model_pool_http_honesty.py @@ -3,18 +3,18 @@ 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_model_pool_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "embeddings_model_pool_http_honesty_token" def build() -> TaskOrchestrator: @@ -48,6 +48,61 @@ def _server(): return server, thread, server.server_address[1] +def _server_without_embedding(): + server = build_server( + TaskOrchestrator([ModelAgent("general_agent", "mock-planner", tags=("reasoning",))]), + port=0, + security=SecurityConfig(auth_token=_TEST_AUTH_TOKEN), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, server.server_address[1] + + +def test_select_capability_agent_normalizes_and_rejects_empty_capability() -> None: + """Capability selection normalizes names and rejects an empty capability.""" + orchestrator = TaskOrchestrator( + [ModelAgent("embedding_agent", "mock-planner", tags=("embedding",))] + ) + assert orchestrator.select_capability_agent(" EMBEDDING ").id == "embedding_agent" + try: + orchestrator.select_capability_agent(" ") + except ValueError as exc: + assert str(exc) == "capability must be a non-empty string" + else: + raise AssertionError("empty capability must fail closed") + + +def test_select_capability_agent_skips_disabled_and_excluded_agents() -> None: + """Capability selection skips disabled and provider-excluded candidates.""" + orchestrator = TaskOrchestrator( + [ + ModelAgent("disabled_embedding", "disabled", tags=("embedding",), disabled=True), + ModelAgent( + "excluded_embedding", + "excluded", + tags=("embedding",), + provider_exclusions=("embedding",), + ), + ModelAgent("eligible_embedding", "eligible", tags=("embedding",)), + ] + ) + assert orchestrator.select_capability_agent("embedding").id == "eligible_embedding" + + unavailable = TaskOrchestrator( + [ + ModelAgent("disabled_embedding", "disabled", tags=("embedding",), disabled=True), + ModelAgent("reasoning_agent", "reasoning", tags=("reasoning",)), + ] + ) + try: + unavailable.select_capability_agent("embedding") + except RuntimeError as exc: + assert str(exc) == "no enabled agent available for capability=embedding" + else: + raise AssertionError("an unavailable capability must fail closed") + + def test_http_embeddings_rejects_model_outside_agent_pool() -> None: server, thread, port = _server() try: @@ -83,6 +138,28 @@ def test_http_embeddings_accepts_model_in_agent_pool() -> None: thread.join(timeout=5) +def test_http_embeddings_auto_selects_enabled_embedding_agent() -> None: + server, thread, port = _server() + try: + status, body = _post(port, "/v1/embeddings", {"input": "invoice search chunk"}) + assert status == 200, body + assert body.get("model") == "mock-planner" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_embeddings_auto_selection_fails_when_capability_is_missing() -> None: + server, thread, port = _server_without_embedding() + try: + status, body = _post(port, "/v1/embeddings", {"input": "invoice search chunk"}) + assert status == 503, body + assert "embedding_unavailable" in json.dumps(body) + finally: + server.shutdown() + thread.join(timeout=5) + + def test_http_batch_embeddings_rejects_model_outside_agent_pool() -> None: server, thread, port = _server() try: @@ -116,9 +193,22 @@ def test_http_batch_embeddings_accepts_model_in_agent_pool() -> None: thread.join(timeout=5) +def test_http_batch_embeddings_auto_selects_enabled_embedding_agent() -> None: + server, thread, port = _server() + try: + status, body = _post(port, "/v1/batch/embeddings", {"inputs": ["alpha", "beta"]}) + assert status == 200, body + assert body.get("model") == "mock-planner" + finally: + server.shutdown() + thread.join(timeout=5) + + if __name__ == "__main__": test_http_embeddings_rejects_model_outside_agent_pool() test_http_embeddings_accepts_model_in_agent_pool() + test_http_embeddings_auto_selects_enabled_embedding_agent() test_http_batch_embeddings_rejects_model_outside_agent_pool() test_http_batch_embeddings_accepts_model_in_agent_pool() + test_http_batch_embeddings_auto_selects_enabled_embedding_agent() print("ok") diff --git a/tests/test_encoding_stream_logprobs_http_honesty.py b/tests/test_encoding_stream_logprobs_http_honesty.py index 955904ad6..6b8d9bfbd 100644 --- a/tests/test_encoding_stream_logprobs_http_honesty.py +++ b/tests/test_encoding_stream_logprobs_http_honesty.py @@ -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 = "encoding_stream_logprobs_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "encoding_stream_logprobs_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"))] ) diff --git a/tests/test_ledger_execution_identity_http_honesty.py b/tests/test_ledger_execution_identity_http_honesty.py index 04c3693ba..ed7c6ae01 100644 --- a/tests/test_ledger_execution_identity_http_honesty.py +++ b/tests/test_ledger_execution_identity_http_honesty.py @@ -3,15 +3,15 @@ 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 ( # noqa: E402 +from contextual_orchestrator import ( CostLedger, CostRoutingCoordinator, InMemoryConfigStore, @@ -20,9 +20,9 @@ PriceEntry, TaskOrchestrator, ) -from contextual_orchestrator.server import SecurityConfig, build_server # noqa: E402 +from contextual_orchestrator.server import SecurityConfig, build_server -_TEST_AUTH_TOKEN = "ledger_execution_identity_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "ledger_execution_identity_http_honesty_token" def _serve(): @@ -32,7 +32,7 @@ def _serve(): model="mock-a", base_url="mock://a", provider_name="mock", - tags=("reasoning", "coding", "writing"), + tags=("reasoning", "coding", "writing", "embedding"), priority=1, ) ] diff --git a/tests/test_mode_casefold_http_honesty.py b/tests/test_mode_casefold_http_honesty.py index 60a6a3df1..beabd1a08 100644 --- a/tests/test_mode_casefold_http_honesty.py +++ b/tests/test_mode_casefold_http_honesty.py @@ -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 = "mode_casefold_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "mode_casefold_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"))] ) diff --git a/tests/test_openai_user_field_http_honesty.py b/tests/test_openai_user_field_http_honesty.py index 91f8c9fdf..6e76f4364 100644 --- a/tests/test_openai_user_field_http_honesty.py +++ b/tests/test_openai_user_field_http_honesty.py @@ -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 = "openai_user_field_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "openai_user_field_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"))] ) diff --git a/tests/test_token_id_whole_float_coerce_http_honesty.py b/tests/test_token_id_whole_float_coerce_http_honesty.py index 93e5f75e5..bc42fa995 100644 --- a/tests/test_token_id_whole_float_coerce_http_honesty.py +++ b/tests/test_token_id_whole_float_coerce_http_honesty.py @@ -8,16 +8,16 @@ 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 ( # noqa: E402 +from contextual_orchestrator import ModelAgent, TaskOrchestrator +from contextual_orchestrator.server import ( SecurityConfig, _coerce_embedding_token_sequence, _coerce_token_id, @@ -27,12 +27,12 @@ build_server, ) -_TEST_AUTH_TOKEN = "token_id_whole_float_coerce_http_honesty_token" # noqa: S105 +_TEST_AUTH_TOKEN = "token_id_whole_float_coerce_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"))] ) From 5a6f563ba86b73769b9f34ed256d6f670bb8ef0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:40:39 +0900 Subject: [PATCH 5/9] docs: assign unique sampling capability ADR number --- ...tion.md => 0016-optional-sampling-capability-negotiation.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/planning/adrs/{0012-optional-sampling-capability-negotiation.md => 0016-optional-sampling-capability-negotiation.md} (96%) diff --git a/docs/planning/adrs/0012-optional-sampling-capability-negotiation.md b/docs/planning/adrs/0016-optional-sampling-capability-negotiation.md similarity index 96% rename from docs/planning/adrs/0012-optional-sampling-capability-negotiation.md rename to docs/planning/adrs/0016-optional-sampling-capability-negotiation.md index cc7642195..7f2174eab 100644 --- a/docs/planning/adrs/0012-optional-sampling-capability-negotiation.md +++ b/docs/planning/adrs/0016-optional-sampling-capability-negotiation.md @@ -1,4 +1,4 @@ -# ADR 0012: Optional sampling capability negotiation +# ADR 0016: Optional sampling capability negotiation - Status: Accepted - Date: 2026-08-20 From 41c0c87ef4c4cf335b71d07b990734944db4b89e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:45:34 -0700 Subject: [PATCH 6/9] feat: opt in to client-owned tool loops (#787) * feat: opt in to client-owned tool loops * test: repair stale standalone test entrypoints --- contextual_orchestrator/server.py | 58 +++++++++++++++++-- .../0014-gateway-owned-model-selection.md | 15 +++-- ...tools_passthrough_controls_http_honesty.py | 37 +++++++++--- ...test_responses_tools_shape_http_honesty.py | 42 +++++++++++--- 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index bbd681af6..ea69ce2f6 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -5244,14 +5244,40 @@ def do_POST(self) -> None: # noqa: N802 frequency_penalty = sampling["frequency_penalty"] # Explicit JSON null on trigger keys is omit-equivalent (SDK optional # defaults) — do not force single-agent passthrough for null-only keys. - if tools_list or isinstance(tool_choice, dict) or ( + tool_passthrough = tools_list or isinstance(tool_choice, dict) or ( isinstance(tool_choice, str) and tool_choice not in {"none", "auto"} - ): + ) + tool_loop_header = self.headers.get( + "x-contextual-orchestrator-tool-loop", "" + ).strip().lower() + if tool_passthrough and tool_loop_header == "v1": + # OpenCode executes the returned function calls in its own + # bounded tool loop. Preserve the full provider response; + # multi-agent synthesis cannot safely merge tool state. + if stream: + raise RequestError( + 400, + "invalid_stream", + "tool-loop passthrough requires stream=false", + ) + started_at = time.perf_counter() + raw_response = self._run(lambda: orchestrator.proxy_completion(body)) + orchestrator.record_analytics_event( + "chat_completion_tool_passthrough", + { + "endpoint_path": "/v1/chat/completions", + "actor_scope": "inference", + "status_code": 200, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + }, + ) + self._send(raw_response) + return + if tool_passthrough: raise RequestError( 422, "multi_agent_tools_unsupported", - "tool execution cannot be represented by the multi-agent synthesis contract yet; " - "a single-agent passthrough is intentionally not used", + "tool execution requires the explicit v1 client-owned tool-loop contract", ) messages = _validate_messages(body.get("messages")) mode = _validate_mode(body.get("orchestration") or body.get("orchestration_mode") or body.get("mode") or "auto") @@ -5654,12 +5680,32 @@ def do_POST(self) -> None: # noqa: N802 _validate_chat_tool_choice(body) if "response_format" in body: _validate_chat_response_format(body) + tool_loop_header = self.headers.get( + "x-contextual-orchestrator-tool-loop", "" + ).strip().lower() + if tools_list and tool_loop_header == "v1": + # The Responses client owns execution of the returned + # function calls; the gateway preserves the full shape. + started_at = time.perf_counter() + raw_response = self._run( + lambda: orchestrator.proxy_completion(body, endpoint="responses") + ) + orchestrator.record_analytics_event( + "responses_tool_passthrough", + { + "endpoint_path": "/v1/responses", + "actor_scope": "inference", + "status_code": 200, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + }, + ) + self._send(raw_response) + return if tools_list: raise RequestError( 422, "multi_agent_tools_unsupported", - "tool execution cannot be represented by the multi-agent synthesis contract yet; " - "a single-agent passthrough is intentionally not used", + "tool execution requires the explicit v1 client-owned tool-loop contract", ) if "modalities" in body: _validate_responses_modalities(body) diff --git a/docs/planning/adrs/0014-gateway-owned-model-selection.md b/docs/planning/adrs/0014-gateway-owned-model-selection.md index b3fdff98a..4d51002ee 100644 --- a/docs/planning/adrs/0014-gateway-owned-model-selection.md +++ b/docs/planning/adrs/0014-gateway-owned-model-selection.md @@ -58,14 +58,19 @@ because the provider response shape is richer. - `json_object`, `json_schema`, and Responses text JSON formats force the conduct workflow. The final synthesis receives the output contract and the gateway validates the resulting JSON locally before returning it. -- Tool-loop requests are not proxied to one agent. Until a multi-agent tool - execution contract exists, they return a named `422` rather than claiming an - orchestrated result. +- Tool-loop requests are explicitly passed to one selected worker agent. The + gateway preserves the provider's full tool-call response and the client owns + execution of the returned function calls; they do not claim a multi-agent + synthesis trace. Streaming tool loops are rejected until the gateway has a + provider-shape-preserving streaming relay. Clients must opt in with the + `X-Contextual-Orchestrator-Tool-Loop: v1` header; ordinary tool requests stay + fail-closed until that contract is explicitly selected. ## Consequences - Provider model selection remains centralized and can change with the registry without an application rebuild. - Structured output retains the multi-agent trace and cannot bypass synthesis. -- Tool callers must wait for a future multi-agent tool protocol; no silent - single-agent fallback is permitted. +- Tool callers use an explicit single-agent passthrough contract. The gateway + remains the model-selection boundary, while tool execution stays with the + authenticated client and never becomes an implicit multi-agent fallback. diff --git a/tests/test_chat_tools_passthrough_controls_http_honesty.py b/tests/test_chat_tools_passthrough_controls_http_honesty.py index 530c22421..9ff435522 100644 --- a/tests/test_chat_tools_passthrough_controls_http_honesty.py +++ b/tests/test_chat_tools_passthrough_controls_http_honesty.py @@ -50,15 +50,18 @@ def build() -> TaskOrchestrator: ) -def _post(port: int, payload: dict) -> tuple[int, dict]: +def _post(port: int, payload: dict, *, tool_loop: bool = False) -> tuple[int, dict]: + headers = { + "content-type": "application/json", + "authorization": f"Bearer {_TEST_AUTH_TOKEN}", + "connection": "close", + } + if tool_loop: + headers["x-contextual-orchestrator-tool-loop"] = "v1" request = urllib.request.Request( f"http://127.0.0.1:{port}/v1/chat/completions", data=json.dumps(payload).encode("utf-8"), - headers={ - "content-type": "application/json", - "authorization": f"Bearer {_TEST_AUTH_TOKEN}", - "connection": "close", - }, + headers=headers, method="POST", ) try: @@ -147,7 +150,7 @@ def test_http_tools_passthrough_rejects_invalid_user_and_stream_options() -> Non thread.join(timeout=5) -def test_http_tools_rejects_valid_tool_request_without_single_agent_fallback() -> None: +def test_http_tools_rejects_valid_tool_request_without_explicit_loop_header() -> None: server, thread, port = _server() try: status, body = _post( @@ -161,6 +164,24 @@ def test_http_tools_rejects_valid_tool_request_without_single_agent_fallback() - thread.join(timeout=5) +def test_http_tools_preserves_valid_tool_request_with_explicit_loop_header() -> None: + """The opt-in contract preserves provider tool state for OpenCode.""" + server, thread, port = _server() + try: + status, body = _post( + port, + _base(temperature="0.7", top_p="0.95", max_tokens="64"), + tool_loop=True, + ) + assert status == 200, body + assert body["echo"]["temperature"] == 0.7 + assert body["echo"]["max_tokens"] == 64 + assert body["echo"]["tools"] == _TOOLS + finally: + server.shutdown() + thread.join(timeout=5) + + def test_http_response_format_passthrough_rejects_seed() -> None: """response_format alone also triggers passthrough — same control gate.""" server, thread, port = _server() @@ -186,6 +207,6 @@ def test_http_response_format_passthrough_rejects_seed() -> None: test_http_tools_passthrough_rejects_invalid_temperature() test_http_tools_passthrough_rejects_unsupported_seed_store_stop_n() test_http_tools_passthrough_rejects_invalid_user_and_stream_options() - test_http_tools_passthrough_accepts_coerced_sampling() + test_unit_sampling_writeback_coerced_numbers() test_http_response_format_passthrough_rejects_seed() print("ok") diff --git a/tests/test_responses_tools_shape_http_honesty.py b/tests/test_responses_tools_shape_http_honesty.py index 13fbe20d5..a15122009 100644 --- a/tests/test_responses_tools_shape_http_honesty.py +++ b/tests/test_responses_tools_shape_http_honesty.py @@ -23,15 +23,18 @@ def build() -> TaskOrchestrator: ) -def _post(port: int, payload: dict) -> tuple[int, dict]: +def _post(port: int, payload: dict, *, tool_loop: bool = False) -> tuple[int, dict]: + headers = { + "content-type": "application/json", + "authorization": f"Bearer {_TEST_AUTH_TOKEN}", + "connection": "close", + } + if tool_loop: + headers["x-contextual-orchestrator-tool-loop"] = "v1" request = urllib.request.Request( f"http://127.0.0.1:{port}/v1/responses", data=json.dumps(payload).encode("utf-8"), - headers={ - "content-type": "application/json", - "authorization": f"Bearer {_TEST_AUTH_TOKEN}", - "connection": "close", - }, + headers=headers, method="POST", ) try: @@ -61,7 +64,7 @@ def _valid_tools() -> list[dict]: ] -def test_http_responses_rejects_tools_until_multi_agent_tool_protocol_exists() -> None: +def test_http_responses_rejects_tools_without_explicit_loop_header() -> None: server, thread, port = _server() try: status, body = _post( @@ -80,6 +83,27 @@ def test_http_responses_rejects_tools_until_multi_agent_tool_protocol_exists() - thread.join(timeout=5) +def test_http_responses_preserves_tools_with_explicit_loop_header() -> None: + """The opt-in Responses contract preserves the provider response shape.""" + server, thread, port = _server() + try: + status, body = _post( + port, + { + "model": "mock-planner", + "input": "use tools", + "tools": _valid_tools(), + "tool_choice": "auto", + }, + tool_loop=True, + ) + assert status == 200, body + assert body["echo"]["tools"] == _valid_tools() + finally: + server.shutdown() + thread.join(timeout=5) + + def test_http_responses_accepts_empty_tools_array_as_noop() -> None: """SDKs often send tools: [] when no tools are configured — honest no-op.""" server, thread, port = _server() @@ -173,8 +197,8 @@ def test_http_responses_rejects_named_tool_choice_not_in_tools() -> None: if __name__ == "__main__": - test_http_responses_rejects_tools_until_multi_agent_tool_protocol_exists() - test_http_responses_rejects_empty_tools_array() + test_http_responses_rejects_tools_without_explicit_loop_header() + test_http_responses_accepts_empty_tools_array_as_noop() test_http_responses_rejects_tool_without_function_type() test_http_responses_accepts_tool_choice_auto_without_tools_as_omit() test_http_responses_rejects_legacy_functions_surface() From dbf46e58e4308468ff35de6beff66511be103f9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:04:29 +0900 Subject: [PATCH 7/9] fix: reject streaming responses tool loops --- contextual_orchestrator/server.py | 39 ++++++++++--------- ...test_responses_tools_shape_http_honesty.py | 37 ++++++++++++++++++ 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index ea69ce2f6..a6dd80b0f 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -5683,25 +5683,7 @@ def do_POST(self) -> None: # noqa: N802 tool_loop_header = self.headers.get( "x-contextual-orchestrator-tool-loop", "" ).strip().lower() - if tools_list and tool_loop_header == "v1": - # The Responses client owns execution of the returned - # function calls; the gateway preserves the full shape. - started_at = time.perf_counter() - raw_response = self._run( - lambda: orchestrator.proxy_completion(body, endpoint="responses") - ) - orchestrator.record_analytics_event( - "responses_tool_passthrough", - { - "endpoint_path": "/v1/responses", - "actor_scope": "inference", - "status_code": 200, - "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), - }, - ) - self._send(raw_response) - return - if tools_list: + if tools_list and tool_loop_header != "v1": raise RequestError( 422, "multi_agent_tools_unsupported", @@ -5766,6 +5748,25 @@ def do_POST(self) -> None: # noqa: N802 "invalid_stream", "stream is not supported on /v1/responses", ) + if tools_list and tool_loop_header == "v1": + # Validate input and stream before passthrough so the + # client-owned contract cannot silently downgrade a + # requested stream or accept a missing input. + started_at = time.perf_counter() + raw_response = self._run( + lambda: orchestrator.proxy_completion(body, endpoint="responses") + ) + orchestrator.record_analytics_event( + "responses_tool_passthrough", + { + "endpoint_path": "/v1/responses", + "actor_scope": "inference", + "status_code": 200, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + }, + ) + self._send(raw_response) + return response_contract: dict[str, Any] | None = None raw_response_format = body.get("response_format") if isinstance(raw_response_format, dict) and raw_response_format.get("type") in { diff --git a/tests/test_responses_tools_shape_http_honesty.py b/tests/test_responses_tools_shape_http_honesty.py index a15122009..9b34eef30 100644 --- a/tests/test_responses_tools_shape_http_honesty.py +++ b/tests/test_responses_tools_shape_http_honesty.py @@ -104,6 +104,43 @@ def test_http_responses_preserves_tools_with_explicit_loop_header() -> None: thread.join(timeout=5) +def test_http_responses_tool_loop_rejects_stream_true() -> None: + """Client-owned Responses tool loops must reject unsupported streaming.""" + server, thread, port = _server() + try: + status, body = _post( + port, + { + "model": "mock-planner", + "input": "stream tools", + "tools": _valid_tools(), + "stream": True, + }, + tool_loop=True, + ) + assert status == 400, body + assert body["error"]["code"] == "invalid_stream" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_tool_loop_requires_input() -> None: + """Client-owned Responses tool loops still require a non-empty input.""" + server, thread, port = _server() + try: + status, body = _post( + port, + {"model": "mock-planner", "tools": _valid_tools()}, + tool_loop=True, + ) + assert status == 400, body + assert body["error"]["code"] == "invalid_input" + finally: + server.shutdown() + thread.join(timeout=5) + + def test_http_responses_accepts_empty_tools_array_as_noop() -> None: """SDKs often send tools: [] when no tools are configured — honest no-op.""" server, thread, port = _server() From ef7b1fb777b056d8180b3dffac2f2abbb8cdb666 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:11:47 +0900 Subject: [PATCH 8/9] fix: enforce total inbound body deadline --- contextual_orchestrator/server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index 9ba999994..e62ab912a 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -5984,14 +5984,21 @@ def _read_json(self) -> dict[str, Any]: if timeout_supported: previous_timeout = connection.gettimeout() connection.settimeout(security.request_read_timeout_seconds) + read_deadline = time.monotonic() + security.request_read_timeout_seconds try: chunks = bytearray() while len(chunks) < body_size: + if time.monotonic() >= read_deadline: + self.close_connection = True + raise RequestError(408, "request_read_timeout", "request body read timed out") chunk = self.rfile.read(body_size - len(chunks)) if not chunk: self.close_connection = True raise RequestError(400, "invalid_request_framing", "request body ended before content-length") chunks.extend(chunk) + if time.monotonic() >= read_deadline: + self.close_connection = True + raise RequestError(408, "request_read_timeout", "request body read timed out") except (TimeoutError, socket.timeout): self.close_connection = True raise RequestError(408, "request_read_timeout", "request body read timed out") from None From 5774e1d2e878bf111de3229c3468d2071908ec81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:24:38 +0900 Subject: [PATCH 9/9] test: keep static checks clean --- tests/test_chat_parallel_tool_calls_http_honesty.py | 8 -------- tests/test_chat_tools_shape_http_honesty.py | 12 ------------ ...nctions_null_max_tool_calls_null_http_honesty.py | 1 - tests/test_responses_flat_tools_http_honesty.py | 9 --------- ...st_responses_logit_bias_logprobs_http_honesty.py | 11 ----------- tests/test_security_hardening.py | 1 - ...st_whole_float_string_int_coerce_http_honesty.py | 13 ------------- 7 files changed, 55 deletions(-) diff --git a/tests/test_chat_parallel_tool_calls_http_honesty.py b/tests/test_chat_parallel_tool_calls_http_honesty.py index 051ad20f9..9760da0f4 100644 --- a/tests/test_chat_parallel_tool_calls_http_honesty.py +++ b/tests/test_chat_parallel_tool_calls_http_honesty.py @@ -135,11 +135,3 @@ def test_http_chat_parallel_tool_calls_true_rejects_single_agent_fallback() -> N finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_parallel_tool_calls_false_without_tools_ok() - test_http_chat_parallel_tool_calls_true_without_tools_fail_closed() - test_http_chat_parallel_tool_calls_non_boolean_fail_closed() - test_http_chat_parallel_tool_calls_true_with_tools_passthrough() - print("ok") diff --git a/tests/test_chat_tools_shape_http_honesty.py b/tests/test_chat_tools_shape_http_honesty.py index 526b7baee..8c054a044 100644 --- a/tests/test_chat_tools_shape_http_honesty.py +++ b/tests/test_chat_tools_shape_http_honesty.py @@ -231,15 +231,3 @@ def test_http_chat_accepts_tools_omitted() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_accepts_valid_function_tools() - test_http_chat_rejects_empty_tools_array() - test_http_chat_rejects_tool_type_not_function() - test_http_chat_rejects_tool_missing_function_name() - test_http_chat_rejects_tool_function_name_bad_charset() - test_http_chat_rejects_tool_sibling_unknown_fields() - test_http_chat_rejects_parameters_non_object() - test_http_chat_accepts_tools_omitted() - print("ok") diff --git a/tests/test_functions_null_max_tool_calls_null_http_honesty.py b/tests/test_functions_null_max_tool_calls_null_http_honesty.py index a2a26bf71..6d7007dca 100644 --- a/tests/test_functions_null_max_tool_calls_null_http_honesty.py +++ b/tests/test_functions_null_max_tool_calls_null_http_honesty.py @@ -103,7 +103,6 @@ def test_http_responses_accepts_null_max_tool_calls_and_functions() -> None: "max_tool_calls": None, "functions": None, "function_call": None, - "functions": [], }, ) assert status == 200, body diff --git a/tests/test_responses_flat_tools_http_honesty.py b/tests/test_responses_flat_tools_http_honesty.py index df162a038..27352b247 100644 --- a/tests/test_responses_flat_tools_http_honesty.py +++ b/tests/test_responses_flat_tools_http_honesty.py @@ -185,12 +185,3 @@ def test_http_tools_rejects_mixed_nested_and_flat() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_responses_accepts_flat_function_tools() - test_http_responses_accepts_flat_tools_with_tool_choice_name() - test_http_chat_still_accepts_nested_function_tools() - test_http_chat_accepts_flat_function_tools_too() - test_http_tools_rejects_mixed_nested_and_flat() - print("ok") diff --git a/tests/test_responses_logit_bias_logprobs_http_honesty.py b/tests/test_responses_logit_bias_logprobs_http_honesty.py index 12c5b6020..7f3911051 100644 --- a/tests/test_responses_logit_bias_logprobs_http_honesty.py +++ b/tests/test_responses_logit_bias_logprobs_http_honesty.py @@ -165,14 +165,3 @@ def test_http_responses_rejects_non_boolean_logprobs() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_responses_accepts_empty_and_valid_logit_bias() - test_http_responses_rejects_non_digit_logit_bias_key() - test_http_responses_rejects_out_of_range_logit_bias_value() - test_http_responses_accepts_logprobs_false() - test_http_responses_rejects_unapplied_logprobs_with_top_logprobs() - test_http_responses_rejects_top_logprobs_without_logprobs() - test_http_responses_rejects_non_boolean_logprobs() - print("ok") diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index 6aa57bf5c..18471581c 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import socket import threading import urllib.error diff --git a/tests/test_whole_float_string_int_coerce_http_honesty.py b/tests/test_whole_float_string_int_coerce_http_honesty.py index f73a67ed6..cfc3c3d6c 100644 --- a/tests/test_whole_float_string_int_coerce_http_honesty.py +++ b/tests/test_whole_float_string_int_coerce_http_honesty.py @@ -221,16 +221,3 @@ def test_http_responses_rejects_unapplied_top_logprobs_whole_float_string() -> N finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_accepts_n_whole_float_string() - test_http_responses_accepts_seed_whole_float_string() - test_http_chat_rejects_fractional_float_string_n() - test_http_chat_accepts_top_logprobs_zero_float_strings() - test_http_chat_still_rejects_nonzero_top_logprobs_float_string() - test_http_completions_accepts_top_logprobs_zero_float_string() - test_http_completions_accepts_best_of_whole_float_string() - test_http_chat_accepts_max_tool_calls_zero_float_string() - test_http_responses_rejects_unapplied_top_logprobs_whole_float_string() - print("ok")