831 lines
28 KiB
Python
831 lines
28 KiB
Python
"""Adapter parameter contracts and request mapping (design doc 6.1-6.3).
|
|
|
|
This module is the single authority for how unified registry parameters are
|
|
validated, normalized, and mapped onto LangChain constructor options and
|
|
provider request fields:
|
|
|
|
- ``adapter_specs`` holds the versioned built-in contracts: a generic
|
|
``model_selector: "*"`` contract for each phase-1 adapter plus the
|
|
``openai-compatible``/``glm-5.2`` model-specific contract.
|
|
- ``find_adapter_spec`` implements the matching order — exact
|
|
``upstream_model_id`` first, longest glob next, generic contract last.
|
|
- ``resolve_parameters`` applies the section 6.1 inheritance semantics and
|
|
the save-time contract checks (stable error codes from section 9.5).
|
|
- ``Adapter.build_request`` is the only entry point that turns a frozen
|
|
``ResolvedModelConfig`` into ``{client_options, request_options}``;
|
|
parameters the contract does not declare never enter the request.
|
|
|
|
A ``target_name`` of ``""`` declares a parameter the contract validates but
|
|
enforces outside the request payload (for example ollama retries, which live
|
|
in the SafeHttpTransport). Dotted ``client_option`` target names (for example
|
|
``client_kwargs.timeout``) build nested option dicts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
from .errors import (
|
|
ADAPTER_NOT_SUPPORTED,
|
|
AUTH_MODE_UNSUPPORTED,
|
|
CAPABILITY_UNSUPPORTED_BY_ADAPTER,
|
|
CREDENTIAL_NOT_CONFIGURED,
|
|
UNSUPPORTED_RUNTIME_PARAMETER,
|
|
ModelRegistryError,
|
|
)
|
|
from .schemas import (
|
|
AdapterParameterSpec,
|
|
AuthSpec,
|
|
Capabilities,
|
|
ConnectionSpec,
|
|
ModelConfig,
|
|
ParameterRule,
|
|
ProviderConfig,
|
|
ReasoningEffort,
|
|
ResolvedModelConfig,
|
|
SamplingOverride,
|
|
)
|
|
|
|
SUPPORTED_ADAPTER_IDS = (
|
|
"openai",
|
|
"anthropic",
|
|
"openai-compatible",
|
|
"anthropic-compatible",
|
|
"ollama",
|
|
)
|
|
|
|
# Known adapter IDs that phase 1 deliberately does not open (section 6.2).
|
|
UNOPENED_ADAPTER_IDS = ("google-genai", "grok", "openrouter", "nvidia", "antigravity")
|
|
|
|
# Unified parameter keys the mapping engine knows how to source (section 6.3).
|
|
_UNIFIED_PARAMETERS = (
|
|
"timeout_seconds",
|
|
"max_retries",
|
|
"max_output_tokens",
|
|
"temperature",
|
|
"top_p",
|
|
"reasoning_effort",
|
|
)
|
|
|
|
_CAPABILITY_NAMES = ("tools", "vision", "structured_output")
|
|
|
|
_REASONING_VALUES = ["low", "medium", "high"]
|
|
|
|
|
|
class BuiltRequest(BaseModel):
|
|
"""The output of ``Adapter.build_request`` (section 6.4)."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
client_options: dict[str, Any]
|
|
request_options: dict[str, Any]
|
|
|
|
|
|
class ResolvedParameters(BaseModel):
|
|
"""Save-time resolution result; the Resolver freezes it into a snapshot."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
timeout_seconds: int
|
|
max_retries: int
|
|
max_output_tokens: int
|
|
temperature: float | None
|
|
top_p: float | None
|
|
# "auto" means the field is omitted from the provider request.
|
|
reasoning_effort: ReasoningEffort
|
|
|
|
|
|
# --- Normalizers (named server-side functions referenced by contracts) ---
|
|
|
|
|
|
class _Omit:
|
|
"""Sentinel: the parameter is omitted from the outbound request."""
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return "OMIT"
|
|
|
|
|
|
OMIT = _Omit()
|
|
|
|
|
|
def _reject_parameter(name: str, message: str) -> ModelRegistryError:
|
|
return ModelRegistryError(
|
|
UNSUPPORTED_RUNTIME_PARAMETER,
|
|
message,
|
|
details=[{"path": f"runtime.{name}", "code": UNSUPPORTED_RUNTIME_PARAMETER}],
|
|
)
|
|
|
|
|
|
def _normalize_identity(name: str, rule: ParameterRule, value: Any) -> Any:
|
|
return value
|
|
|
|
|
|
def _normalize_clamp_to_model_limit(name: str, rule: ParameterRule, value: Any) -> Any:
|
|
if value is None:
|
|
return OMIT
|
|
if rule.maximum is not None and value > rule.maximum:
|
|
return rule.maximum
|
|
if rule.minimum is not None and value < rule.minimum:
|
|
raise _reject_parameter(
|
|
name,
|
|
f"{name}={value} is below the contract minimum {rule.minimum}.",
|
|
)
|
|
return value
|
|
|
|
|
|
def _normalize_omit_when_none(name: str, rule: ParameterRule, value: Any) -> Any:
|
|
return OMIT if value is None else value
|
|
|
|
|
|
def _normalize_omit_when_auto(name: str, rule: ParameterRule, value: Any) -> Any:
|
|
return OMIT if value is None or value == "auto" else value
|
|
|
|
|
|
def _normalize_reject_non_auto(name: str, rule: ParameterRule, value: Any) -> Any:
|
|
if value is None or value == "auto":
|
|
return OMIT
|
|
raise _reject_parameter(
|
|
name,
|
|
f"{name} is not supported by this adapter contract; only the "
|
|
"inheriting 'auto' value is accepted.",
|
|
)
|
|
|
|
|
|
_NORMALIZERS = {
|
|
"identity": _normalize_identity,
|
|
"clamp_to_model_limit": _normalize_clamp_to_model_limit,
|
|
"omit_when_none": _normalize_omit_when_none,
|
|
"omit_when_auto": _normalize_omit_when_auto,
|
|
"reject_non_auto": _normalize_reject_non_auto,
|
|
}
|
|
|
|
|
|
def _check_rule_value(name: str, rule: ParameterRule, value: Any) -> None:
|
|
"""Type/range/enum validation for a non-null value against the contract.
|
|
|
|
With the ``clamp_to_model_limit`` normalizer an out-of-range high value is
|
|
clamped by the normalizer instead of rejected, so the maximum check is
|
|
left to it.
|
|
"""
|
|
if rule.value_type == "integer":
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise _reject_parameter(name, f"{name} must be an integer.")
|
|
elif rule.value_type == "number":
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise _reject_parameter(name, f"{name} must be a number.")
|
|
elif rule.value_type == "enum":
|
|
if rule.enum_values is not None and value not in rule.enum_values:
|
|
raise _reject_parameter(name, f"{name} must be one of {rule.enum_values}.")
|
|
if rule.value_type in ("integer", "number"):
|
|
if rule.minimum is not None and value < rule.minimum:
|
|
raise _reject_parameter(
|
|
name,
|
|
f"{name}={value} is below the contract minimum {rule.minimum}.",
|
|
)
|
|
if (
|
|
rule.normalizer != "clamp_to_model_limit"
|
|
and rule.maximum is not None
|
|
and value > rule.maximum
|
|
):
|
|
raise _reject_parameter(
|
|
name,
|
|
f"{name}={value} exceeds the contract maximum {rule.maximum}.",
|
|
)
|
|
|
|
|
|
# --- Built-in contracts (section 6.2) ---
|
|
|
|
|
|
def _connection(chat_model: str) -> ConnectionSpec:
|
|
return ConnectionSpec(
|
|
chat_model=chat_model, model_field="model", base_url_field="base_url"
|
|
)
|
|
|
|
|
|
def _timeout_rule(target_name: str) -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="integer",
|
|
minimum=10,
|
|
maximum=600,
|
|
nullable="forbidden",
|
|
target="client_option",
|
|
target_name=target_name,
|
|
normalizer="identity",
|
|
)
|
|
|
|
|
|
def _retries_rule(target_name: str) -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="integer",
|
|
minimum=0,
|
|
maximum=5,
|
|
nullable="forbidden",
|
|
target="client_option",
|
|
target_name=target_name,
|
|
normalizer="identity",
|
|
)
|
|
|
|
|
|
def _max_output_tokens_rule(
|
|
target_name: str, maximum: float | None = None
|
|
) -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="integer",
|
|
minimum=1,
|
|
maximum=maximum,
|
|
nullable="forbidden",
|
|
target="request_option",
|
|
target_name=target_name,
|
|
normalizer="clamp_to_model_limit",
|
|
)
|
|
|
|
|
|
def _temperature_rule(maximum: float) -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="number",
|
|
minimum=0,
|
|
maximum=maximum,
|
|
nullable="omit",
|
|
target="request_option",
|
|
target_name="temperature",
|
|
normalizer="omit_when_none",
|
|
)
|
|
|
|
|
|
def _top_p_rule() -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="number",
|
|
minimum=0.000001,
|
|
maximum=1,
|
|
nullable="omit",
|
|
target="request_option",
|
|
target_name="top_p",
|
|
normalizer="omit_when_none",
|
|
)
|
|
|
|
|
|
def _reasoning_supported_rule() -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=True,
|
|
value_type="enum",
|
|
nullable="omit",
|
|
enum_values=list(_REASONING_VALUES),
|
|
target="request_option",
|
|
target_name="reasoning_effort",
|
|
normalizer="omit_when_auto",
|
|
)
|
|
|
|
|
|
def _reasoning_unsupported_rule() -> ParameterRule:
|
|
return ParameterRule(
|
|
supported=False,
|
|
value_type="enum",
|
|
nullable="omit",
|
|
enum_values=list(_REASONING_VALUES),
|
|
target="request_option",
|
|
target_name="",
|
|
normalizer="reject_non_auto",
|
|
)
|
|
|
|
|
|
def _api_key_auth() -> AuthSpec:
|
|
return AuthSpec(
|
|
credential_required=True,
|
|
credential_kind="api_key",
|
|
target="client_option",
|
|
target_name="api_key",
|
|
)
|
|
|
|
|
|
def _bearer_auth() -> AuthSpec:
|
|
return AuthSpec(
|
|
credential_required=True,
|
|
credential_kind="bearer_token",
|
|
target="request_header",
|
|
target_name="Authorization",
|
|
)
|
|
|
|
|
|
def _openai_style_parameters() -> dict[str, ParameterRule]:
|
|
return {
|
|
"timeout_seconds": _timeout_rule("timeout"),
|
|
"max_retries": _retries_rule("max_retries"),
|
|
"max_output_tokens": _max_output_tokens_rule("max_tokens"),
|
|
"temperature": _temperature_rule(2),
|
|
"top_p": _top_p_rule(),
|
|
"reasoning_effort": _reasoning_supported_rule(),
|
|
}
|
|
|
|
|
|
def _anthropic_style_parameters() -> dict[str, ParameterRule]:
|
|
return {
|
|
"timeout_seconds": _timeout_rule("timeout"),
|
|
"max_retries": _retries_rule("max_retries"),
|
|
"max_output_tokens": _max_output_tokens_rule("max_tokens"),
|
|
"temperature": _temperature_rule(1),
|
|
"top_p": _top_p_rule(),
|
|
"reasoning_effort": _reasoning_unsupported_rule(),
|
|
}
|
|
|
|
|
|
def _build_builtin_specs() -> tuple[AdapterParameterSpec, ...]:
|
|
openai_generic = AdapterParameterSpec(
|
|
adapter_id="openai",
|
|
spec_revision=1,
|
|
model_selector="*",
|
|
auth_specs={"api_key": _api_key_auth()},
|
|
parameters=_openai_style_parameters(),
|
|
protocol_capabilities=Capabilities(
|
|
tools=True, vision=True, structured_output=True
|
|
),
|
|
connection=_connection("ChatOpenAI"),
|
|
)
|
|
openai_compatible_generic = AdapterParameterSpec(
|
|
adapter_id="openai-compatible",
|
|
spec_revision=1,
|
|
model_selector="*",
|
|
auth_specs={"api_key": _api_key_auth(), "bearer": _bearer_auth()},
|
|
parameters=_openai_style_parameters(),
|
|
protocol_capabilities=Capabilities(
|
|
tools=True, vision=True, structured_output=True
|
|
),
|
|
connection=_connection("ChatOpenAI"),
|
|
)
|
|
anthropic_generic = AdapterParameterSpec(
|
|
adapter_id="anthropic",
|
|
spec_revision=1,
|
|
model_selector="*",
|
|
auth_specs={"api_key": _api_key_auth()},
|
|
parameters=_anthropic_style_parameters(),
|
|
protocol_capabilities=Capabilities(
|
|
tools=True, vision=True, structured_output=False
|
|
),
|
|
connection=_connection("ChatAnthropic"),
|
|
)
|
|
anthropic_compatible_generic = anthropic_generic.model_copy(
|
|
update={"adapter_id": "anthropic-compatible"}
|
|
)
|
|
ollama_generic = AdapterParameterSpec(
|
|
adapter_id="ollama",
|
|
spec_revision=1,
|
|
model_selector="*",
|
|
auth_specs={
|
|
"none": AuthSpec(
|
|
credential_required=False,
|
|
credential_kind="none",
|
|
target="adapter_internal",
|
|
target_name=None,
|
|
)
|
|
},
|
|
parameters={
|
|
"timeout_seconds": _timeout_rule("client_kwargs.timeout"),
|
|
# Retries are enforced by the SafeHttpTransport built in Task 2,
|
|
# not by an ollama client option; the contract still validates
|
|
# the registry value (empty target_name = validated, not sent).
|
|
"max_retries": _retries_rule(""),
|
|
"max_output_tokens": _max_output_tokens_rule("num_predict"),
|
|
"temperature": _temperature_rule(2),
|
|
"top_p": _top_p_rule(),
|
|
"reasoning_effort": _reasoning_unsupported_rule(),
|
|
},
|
|
protocol_capabilities=Capabilities(
|
|
tools=True, vision=True, structured_output=True
|
|
),
|
|
connection=_connection("ChatOllama"),
|
|
)
|
|
# The first model-specific contract (section 6.2 YAML, verbatim values).
|
|
glm_52 = AdapterParameterSpec(
|
|
adapter_id="openai-compatible",
|
|
spec_revision=1,
|
|
model_selector="glm-5.2",
|
|
auth_specs={"api_key": _api_key_auth()},
|
|
parameters={
|
|
"timeout_seconds": _timeout_rule("timeout"),
|
|
"max_retries": _retries_rule("max_retries"),
|
|
"max_output_tokens": _max_output_tokens_rule("max_tokens", maximum=32768),
|
|
"temperature": _temperature_rule(1),
|
|
"top_p": _top_p_rule(),
|
|
"reasoning_effort": _reasoning_unsupported_rule(),
|
|
},
|
|
protocol_capabilities=Capabilities(
|
|
tools=True, vision=False, structured_output=True
|
|
),
|
|
connection=_connection("ChatOpenAI"),
|
|
)
|
|
return (
|
|
openai_generic,
|
|
openai_compatible_generic,
|
|
anthropic_generic,
|
|
anthropic_compatible_generic,
|
|
ollama_generic,
|
|
glm_52,
|
|
)
|
|
|
|
|
|
_BUILTIN_SPECS: tuple[AdapterParameterSpec, ...] = _build_builtin_specs()
|
|
|
|
|
|
def adapter_specs() -> tuple[AdapterParameterSpec, ...]:
|
|
"""Return the built-in versioned adapter contracts."""
|
|
return _BUILTIN_SPECS
|
|
|
|
|
|
def find_adapter_spec(
|
|
adapter_id: str,
|
|
upstream_model_id: str,
|
|
*,
|
|
spec_revision: int | None = None,
|
|
specs: list[AdapterParameterSpec] | tuple[AdapterParameterSpec, ...] | None = None,
|
|
) -> AdapterParameterSpec | None:
|
|
"""Match a contract: exact ID first, longest glob next, ``*`` last.
|
|
|
|
Unopened or unknown adapter IDs raise ``ADAPTER_NOT_SUPPORTED``. ``None``
|
|
is returned when no contract matches (or the pinned ``spec_revision`` is
|
|
gone); such configurations may only be saved as ``configured``.
|
|
"""
|
|
if adapter_id not in SUPPORTED_ADAPTER_IDS:
|
|
note = (
|
|
"a known but not yet opened adapter"
|
|
if adapter_id in UNOPENED_ADAPTER_IDS
|
|
else "an unknown adapter"
|
|
)
|
|
raise ModelRegistryError(
|
|
ADAPTER_NOT_SUPPORTED,
|
|
f"Adapter {adapter_id!r} is {note}; phase 1 supports "
|
|
f"{list(SUPPORTED_ADAPTER_IDS)}.",
|
|
)
|
|
candidates = [
|
|
spec
|
|
for spec in (adapter_specs() if specs is None else specs)
|
|
if spec.adapter_id == adapter_id
|
|
and (spec_revision is None or spec.spec_revision == spec_revision)
|
|
]
|
|
for spec in candidates:
|
|
if spec.model_selector == upstream_model_id:
|
|
return spec
|
|
globs = [
|
|
spec
|
|
for spec in candidates
|
|
if spec.model_selector != "*"
|
|
and fnmatch.fnmatchcase(upstream_model_id, spec.model_selector)
|
|
]
|
|
if globs:
|
|
return max(globs, key=lambda spec: len(spec.model_selector))
|
|
for spec in candidates:
|
|
if spec.model_selector == "*":
|
|
return spec
|
|
return None
|
|
|
|
|
|
def get_adapter(
|
|
adapter_id: str,
|
|
upstream_model_id: str,
|
|
*,
|
|
spec_revision: int | None = None,
|
|
) -> Adapter:
|
|
"""Return the matched Adapter, failing loudly when no contract applies."""
|
|
spec = find_adapter_spec(adapter_id, upstream_model_id, spec_revision=spec_revision)
|
|
if spec is None:
|
|
revision_note = (
|
|
f" at spec_revision {spec_revision}" if spec_revision is not None else ""
|
|
)
|
|
raise ModelRegistryError(
|
|
ADAPTER_NOT_SUPPORTED,
|
|
f"No adapter contract matches {adapter_id!r}/{upstream_model_id!r}"
|
|
f"{revision_note}; the configuration may only remain 'configured'.",
|
|
)
|
|
return Adapter(spec)
|
|
|
|
|
|
def compute_effective_capabilities(
|
|
protocol_capabilities: Capabilities,
|
|
declared_capabilities: Capabilities,
|
|
verified_capabilities: Capabilities,
|
|
) -> Capabilities:
|
|
"""The single capability rule: protocol AND declared AND verified."""
|
|
return Capabilities(
|
|
**{
|
|
name: (
|
|
getattr(protocol_capabilities, name)
|
|
and getattr(declared_capabilities, name)
|
|
and getattr(verified_capabilities, name)
|
|
)
|
|
for name in _CAPABILITY_NAMES
|
|
}
|
|
)
|
|
|
|
|
|
def _resolve_nullable_parameter(
|
|
name: str,
|
|
rule: ParameterRule,
|
|
value: Any,
|
|
) -> Any:
|
|
"""Validate one already-inherited value; returns the value or ``OMIT``."""
|
|
if not rule.supported:
|
|
if value is None or value == "auto":
|
|
return OMIT
|
|
# A concrete value for an unsupported parameter is handed to the
|
|
# contract-declared normalizer: reject_non_auto raises here. Any
|
|
# normalizer that would let the value through still rejects, because
|
|
# an unsupported parameter must never enter the request.
|
|
resolved = _NORMALIZERS[rule.normalizer](name, rule, value)
|
|
if resolved is not OMIT:
|
|
raise _reject_parameter(
|
|
name, f"{name} is not supported by this adapter contract."
|
|
)
|
|
return OMIT
|
|
if value is None or (name == "reasoning_effort" and value == "auto"):
|
|
if rule.nullable == "forbidden":
|
|
raise _reject_parameter(name, f"{name} must have a concrete value.")
|
|
return OMIT
|
|
_check_rule_value(name, rule, value)
|
|
return _NORMALIZERS[rule.normalizer](name, rule, value)
|
|
|
|
|
|
def _resolve_required_parameter(
|
|
name: str,
|
|
rule: ParameterRule,
|
|
value: Any,
|
|
) -> Any:
|
|
"""Validate a registry-sourced value that must always resolve."""
|
|
if not rule.supported:
|
|
raise _reject_parameter(
|
|
name, f"{name} is not supported by this adapter contract."
|
|
)
|
|
if value is None:
|
|
raise _reject_parameter(name, f"{name} must have a concrete value.")
|
|
_check_rule_value(name, rule, value)
|
|
resolved = _NORMALIZERS[rule.normalizer](name, rule, value)
|
|
if resolved is OMIT:
|
|
raise _reject_parameter(name, f"{name} must have a concrete value.")
|
|
return resolved
|
|
|
|
|
|
def _parameter_rule(spec: AdapterParameterSpec, name: str) -> ParameterRule:
|
|
rule = spec.parameters.get(name)
|
|
if rule is None:
|
|
raise _reject_parameter(
|
|
name, f"Adapter contract {spec.adapter_id!r} does not declare {name}."
|
|
)
|
|
return rule
|
|
|
|
|
|
def resolve_parameters(
|
|
provider: ProviderConfig,
|
|
model: ModelConfig,
|
|
spec: AdapterParameterSpec,
|
|
*,
|
|
reasoning_effort_override: ReasoningEffort | None = None,
|
|
sampling_override: SamplingOverride | None = None,
|
|
) -> ResolvedParameters:
|
|
"""Resolve a model's runtime parameters against the matched contract.
|
|
|
|
Applies the section 6.1 inheritance semantics (model value overrides the
|
|
provider default; provider ``null`` means the field is omitted, never
|
|
zero; ``reasoning_effort=auto`` inherits and is omitted when still auto)
|
|
and the save-time contract checks. Raises ``ModelRegistryError`` with a
|
|
stable section 9.5 code on any violation.
|
|
|
|
The sampling override (snapshot creation only) replaces the model's
|
|
configured value for its ``kind`` and omits the other sampling
|
|
parameter entirely — registry defaults included — because temperature
|
|
and top_p must not be sent together. Overrides flow through the same
|
|
contract rules, so unsupported adapters still reject them.
|
|
"""
|
|
auth_spec = spec.auth_specs.get(provider.auth.mode)
|
|
if auth_spec is None:
|
|
raise ModelRegistryError(
|
|
AUTH_MODE_UNSUPPORTED,
|
|
f"Adapter {spec.adapter_id!r} does not support auth mode "
|
|
f"{provider.auth.mode!r}.",
|
|
details=[{"path": "auth.mode", "code": AUTH_MODE_UNSUPPORTED}],
|
|
)
|
|
if auth_spec.credential_required and provider.auth.credential_id is None:
|
|
raise ModelRegistryError(
|
|
CREDENTIAL_NOT_CONFIGURED,
|
|
f"Auth mode {provider.auth.mode!r} requires a credential reference.",
|
|
details=[{"path": "auth.credential_id", "code": CREDENTIAL_NOT_CONFIGURED}],
|
|
)
|
|
|
|
for name in _CAPABILITY_NAMES:
|
|
if getattr(model.runtime.declared_capabilities, name) and not getattr(
|
|
spec.protocol_capabilities, name
|
|
):
|
|
raise ModelRegistryError(
|
|
CAPABILITY_UNSUPPORTED_BY_ADAPTER,
|
|
f"Adapter {spec.adapter_id!r} protocol does not support the "
|
|
f"declared capability {name!r}.",
|
|
details=[
|
|
{
|
|
"path": f"runtime.declared_capabilities.{name}",
|
|
"code": CAPABILITY_UNSUPPORTED_BY_ADAPTER,
|
|
}
|
|
],
|
|
)
|
|
|
|
timeout_seconds = _resolve_required_parameter(
|
|
"timeout_seconds",
|
|
_parameter_rule(spec, "timeout_seconds"),
|
|
provider.runtime.timeout_seconds,
|
|
)
|
|
max_retries = _resolve_required_parameter(
|
|
"max_retries",
|
|
_parameter_rule(spec, "max_retries"),
|
|
provider.runtime.max_retries,
|
|
)
|
|
max_output_tokens = _resolve_required_parameter(
|
|
"max_output_tokens",
|
|
_parameter_rule(spec, "max_output_tokens"),
|
|
model.runtime.max_output_tokens,
|
|
)
|
|
|
|
if sampling_override is not None and sampling_override.kind == "temperature":
|
|
temperature = _resolve_nullable_parameter(
|
|
"temperature", _parameter_rule(spec, "temperature"), sampling_override.value
|
|
)
|
|
top_p = _resolve_nullable_parameter(
|
|
"top_p", _parameter_rule(spec, "top_p"), None
|
|
)
|
|
elif sampling_override is not None:
|
|
top_p = _resolve_nullable_parameter(
|
|
"top_p", _parameter_rule(spec, "top_p"), sampling_override.value
|
|
)
|
|
temperature = _resolve_nullable_parameter(
|
|
"temperature", _parameter_rule(spec, "temperature"), None
|
|
)
|
|
else:
|
|
temperature = model.runtime.temperature
|
|
if temperature is None:
|
|
temperature = provider.runtime.default_temperature
|
|
temperature = _resolve_nullable_parameter(
|
|
"temperature", _parameter_rule(spec, "temperature"), temperature
|
|
)
|
|
|
|
top_p = model.runtime.top_p
|
|
if top_p is None:
|
|
top_p = provider.runtime.default_top_p
|
|
top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p)
|
|
|
|
reasoning_effort: ReasoningEffort = (
|
|
reasoning_effort_override
|
|
if reasoning_effort_override not in (None, "auto")
|
|
else model.runtime.reasoning_effort
|
|
)
|
|
if reasoning_effort == "auto":
|
|
reasoning_effort = provider.runtime.default_reasoning_effort
|
|
reasoning_effort = _resolve_nullable_parameter(
|
|
"reasoning_effort",
|
|
_parameter_rule(spec, "reasoning_effort"),
|
|
reasoning_effort,
|
|
)
|
|
|
|
resolved = {
|
|
"timeout_seconds": timeout_seconds,
|
|
"max_retries": max_retries,
|
|
"max_output_tokens": max_output_tokens,
|
|
"temperature": None if temperature is OMIT else temperature,
|
|
"top_p": None if top_p is OMIT else top_p,
|
|
"reasoning_effort": ("auto" if reasoning_effort is OMIT else reasoning_effort),
|
|
}
|
|
_check_conflicts(spec, resolved)
|
|
return ResolvedParameters(**resolved)
|
|
|
|
|
|
def _check_conflicts(spec: AdapterParameterSpec, resolved: dict[str, Any]) -> None:
|
|
present = {
|
|
name
|
|
for name, value in resolved.items()
|
|
if value is not None and value != "auto"
|
|
}
|
|
for name in present:
|
|
rule = spec.parameters.get(name)
|
|
if rule is None:
|
|
continue
|
|
for other in rule.conflicts_with:
|
|
if other in present:
|
|
raise _reject_parameter(
|
|
name, f"{name} conflicts with {other} in this contract."
|
|
)
|
|
|
|
|
|
def _assign_option(options: dict[str, Any], dotted_name: str, value: Any) -> None:
|
|
parts = dotted_name.split(".")
|
|
target = options
|
|
for part in parts[:-1]:
|
|
existing = target.get(part)
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
target[part] = existing
|
|
target = existing
|
|
target[parts[-1]] = value
|
|
|
|
|
|
class Adapter:
|
|
"""A versioned parameter contract bound to one ``AdapterParameterSpec``."""
|
|
|
|
def __init__(self, spec: AdapterParameterSpec) -> None:
|
|
for name, rule in spec.parameters.items():
|
|
if name not in _UNIFIED_PARAMETERS:
|
|
raise ValueError(f"Unknown unified parameter {name!r} in contract.")
|
|
if rule.normalizer not in _NORMALIZERS:
|
|
raise ValueError(f"Unknown normalizer {rule.normalizer!r}.")
|
|
self._spec = spec
|
|
|
|
@property
|
|
def spec(self) -> AdapterParameterSpec:
|
|
return self._spec
|
|
|
|
def build_request(
|
|
self,
|
|
resolved_config: ResolvedModelConfig,
|
|
*,
|
|
credential: str | None = None,
|
|
) -> BuiltRequest:
|
|
"""Map a frozen config into ``{client_options, request_options}``.
|
|
|
|
This is the only entry point that constructs LangChain parameters and
|
|
provider request parameters. Contract validation is re-applied so a
|
|
stale snapshot fails loudly instead of silently sending values the
|
|
current contract would reject.
|
|
"""
|
|
spec = self._spec
|
|
auth_spec = spec.auth_specs.get(resolved_config.auth_ref.mode)
|
|
if auth_spec is None:
|
|
raise ModelRegistryError(
|
|
AUTH_MODE_UNSUPPORTED,
|
|
f"Adapter {spec.adapter_id!r} does not support auth mode "
|
|
f"{resolved_config.auth_ref.mode!r}.",
|
|
)
|
|
if auth_spec.credential_required and credential is None:
|
|
raise ModelRegistryError(
|
|
CREDENTIAL_NOT_CONFIGURED,
|
|
"A credential is required to build the request but none was "
|
|
"resolved for this run.",
|
|
)
|
|
|
|
client_options: dict[str, Any] = {}
|
|
if spec.connection is not None:
|
|
client_options[spec.connection.model_field] = (
|
|
resolved_config.upstream_model_id
|
|
)
|
|
client_options[spec.connection.base_url_field] = resolved_config.base_url
|
|
|
|
sources: dict[str, Any] = {
|
|
"timeout_seconds": resolved_config.client_options.timeout_seconds,
|
|
"max_retries": resolved_config.client_options.max_retries,
|
|
"max_output_tokens": resolved_config.request_options.max_output_tokens,
|
|
"temperature": resolved_config.request_options.temperature,
|
|
"top_p": resolved_config.request_options.top_p,
|
|
"reasoning_effort": resolved_config.request_options.reasoning_effort,
|
|
}
|
|
request_options: dict[str, Any] = {}
|
|
for name, rule in spec.parameters.items():
|
|
value = _resolve_nullable_parameter(name, rule, sources[name])
|
|
if value is OMIT or rule.target_name == "":
|
|
continue
|
|
if rule.target == "client_option":
|
|
_assign_option(client_options, rule.target_name, value)
|
|
elif rule.target == "request_option":
|
|
_assign_option(request_options, rule.target_name, value)
|
|
else: # extra_body_path
|
|
extra_body = request_options.get("extra_body")
|
|
if not isinstance(extra_body, dict):
|
|
extra_body = {}
|
|
request_options["extra_body"] = extra_body
|
|
_assign_option(extra_body, rule.target_name, value)
|
|
|
|
if resolved_config.auth_ref.mode != "none" and credential is not None:
|
|
self._apply_auth(auth_spec, client_options, credential)
|
|
|
|
return BuiltRequest(
|
|
client_options=client_options, request_options=request_options
|
|
)
|
|
|
|
@staticmethod
|
|
def _apply_auth(
|
|
auth_spec: AuthSpec, client_options: dict[str, Any], credential: str
|
|
) -> None:
|
|
if auth_spec.target == "client_option" and auth_spec.target_name:
|
|
client_options[auth_spec.target_name] = credential
|
|
elif auth_spec.target == "request_header" and auth_spec.target_name:
|
|
header_value = (
|
|
f"Bearer {credential}"
|
|
if auth_spec.credential_kind == "bearer_token"
|
|
else credential
|
|
)
|
|
headers = client_options.get("default_headers")
|
|
if not isinstance(headers, dict):
|
|
headers = {}
|
|
client_options["default_headers"] = headers
|
|
headers[auth_spec.target_name] = header_value
|
|
# adapter_internal credentials are handled by the adapter itself and
|
|
# never appear in client or request options.
|