diff --git a/codenib/compiler/index_builders.py b/codenib/compiler/index_builders.py index 563e777d..cf2db059 100644 --- a/codenib/compiler/index_builders.py +++ b/codenib/compiler/index_builders.py @@ -29,6 +29,13 @@ DEFAULT_EMBEDDING_MODEL, resolve_embedding_load_policy, ) +from ..provider_routes import ( + InferenceRoute, + normalize_endpoint, + normalize_provider, + resolve_inference_route, + validate_embedding_runtime_options, +) from ..repository_filters import ( REPOSITORY_FILTER_POLICY_VERSION, default_exclude_patterns, @@ -159,19 +166,31 @@ class VectorIndexBuilder: embedding_provider: str = "huggingface" embedding_dimension: int = DEFAULT_EMBEDDING_DIMENSION embedding_kwargs: Dict[str, Any] = field(default_factory=dict) + embedding_endpoint: Optional[str] = None + embedding_credential_env: Optional[str] = None + embedding_runtime_kwargs: Dict[str, Any] = field( + default_factory=dict, + repr=False, + compare=False, + ) build_levels: List[str] = field(default_factory=lambda: ["l0", "l2"]) max_lines_per_chunk: int = 300 index_metric: str = "ip" def artifact_identity(self) -> Dict[str, Any]: """Return the embedding contract required to reopen this artifact.""" + + route = self._embedding_route() return { - "builder_schema": 2, - "embedding_model": self.embedding_model, - "embedding_provider": self.embedding_provider, + "builder_schema": 3, + "embedding_model": route.model, + "embedding_provider": route.provider, "embedding_dimension": self.embedding_dimension, "dimension": self.embedding_dimension, - "embedding_kwargs": dict(self.embedding_kwargs), + "embedding_endpoint": route.endpoint, + "embedding_kwargs": route.compatibility_options, + "embedding_route": route.public_identity(), + "embedding_fingerprint": route.compatibility_fingerprint, "index_metric": self.index_metric, "languages": list(self.languages), "levels": list(self.build_levels), @@ -179,6 +198,66 @@ def artifact_identity(self) -> Dict[str, Any]: "repository_filter_policy": REPOSITORY_FILTER_POLICY_VERSION, } + def _embedding_route(self) -> InferenceRoute: + return resolve_inference_route( + operation="embeddings", + provider=self.embedding_provider, + model=self.embedding_model, + endpoint=self.embedding_endpoint, + dimension=self.embedding_dimension, + credential_env=self.embedding_credential_env, + compatibility_options=self.embedding_kwargs, + ) + + def _embedding_call_kwargs(self) -> Dict[str, Any]: + route = self._embedding_route() + if route.provider == "huggingface": + kwargs = dict(self.embedding_kwargs) + inherited_runtime: Dict[str, Any] = {} + else: + kwargs = route.embedding_backend_kwargs() + inherited_runtime = { + key: value + for key, value in self.embedding_kwargs.items() + if key not in route.compatibility_options + } + inherited_runtime.update(self.embedding_runtime_kwargs) + runtime_kwargs = validate_embedding_runtime_options( + inherited_runtime, + provider=route.provider, + ) + for key, value in runtime_kwargs.items(): + if key in {"encode_kwargs", "model_kwargs"}: + nested = dict(kwargs.get(key) or {}) + nested.update(value) + kwargs[key] = nested + else: + kwargs[key] = value + runtime_endpoint = normalize_endpoint(kwargs.get("base_url")) + if runtime_endpoint is not None and runtime_endpoint != route.endpoint: + raise ValueError( + "embedding runtime base_url does not match the artifact endpoint" + ) + if route.endpoint: + kwargs["base_url"] = route.endpoint + if not kwargs.get("api_key"): + credential = route.credential() + if credential: + kwargs["api_key"] = credential + return kwargs + + def _validate_vector_dimension(self, vector_store: Any) -> None: + actual = getattr(vector_store, "dimension", None) + if ( + isinstance(actual, int) + and not isinstance(actual, bool) + and actual != self.embedding_dimension + ): + raise ValueError( + "embedding provider returned dimension " + f"{actual}, expected {self.embedding_dimension}" + ) + def build(self, scope: str, **kwargs: Any) -> IndexStatus: repo_path: str = kwargs["repo_path"] output_dir: str = kwargs["output_dir"] @@ -193,6 +272,7 @@ def build(self, scope: str, **kwargs: Any) -> IndexStatus: ) os.makedirs(output_dir, exist_ok=True) + artifact_identity = self.artifact_identity() vs = build_hierarchical_vector_store( repo_path=repo_path, index_path=output_dir, @@ -200,17 +280,19 @@ def build(self, scope: str, **kwargs: Any) -> IndexStatus: languages=self.languages, max_lines_per_chunk=self.max_lines_per_chunk, build_levels=self.build_levels, - embedding_model=self.embedding_model, - embedding_provider=self.embedding_provider, + embedding_model=artifact_identity["embedding_model"], + embedding_provider=artifact_identity["embedding_provider"], embedding_dimension=self.embedding_dimension, - embedding_kwargs=self.embedding_kwargs, + embedding_kwargs=self._embedding_call_kwargs(), index_metric=self.index_metric, + artifact_metadata=artifact_identity, # ``build`` is the compiler's full-materialization path. Reusing an # artifact merely because its model config exists would let stale # vectors be stamped with the current source fingerprint. Cross- # commit reuse belongs to ``incremental_update`` instead. force_rebuild=True, ) + self._validate_vector_dimension(vs) doc_count = {} if hasattr(vs, "l0_documents") and vs.l0_documents: @@ -301,7 +383,7 @@ def _docs_to_chunks(docs): scope=scope, path=output_dir, metadata={ - **self.artifact_identity(), + **artifact_identity, "document_count": doc_count, "last_commit": head_commit, }, @@ -371,14 +453,17 @@ def incremental_update(self, scope: str, **kwargs: Any) -> IndexStatus: return self.build(scope, **kwargs) # Load existing artifacts + artifact_identity = self.artifact_identity() vector_store = CodeVectorStore( - embedding_model=self.embedding_model, - embedding_provider=self.embedding_provider, + embedding_model=artifact_identity["embedding_model"], + embedding_provider=artifact_identity["embedding_provider"], dimension=self.embedding_dimension, index_metric=self.index_metric, store_path=output_dir, - **self.embedding_kwargs, + artifact_metadata=artifact_identity, + **self._embedding_call_kwargs(), ) + self._validate_vector_dimension(vector_store) vector_store.load(output_dir) chunk_store = IncrementalChunkStore.load(chunk_store_path) @@ -899,6 +984,9 @@ def register_default_builders( languages: Optional[List[str]] = None, graph_route: str = "active", embedding_model: str = DEFAULT_EMBEDDING_MODEL, + embedding_provider: str = "huggingface", + embedding_endpoint: Optional[str] = None, + embedding_credential_env: Optional[str] = None, embedding_revision: Optional[str] = None, embedding_dimension: int = DEFAULT_EMBEDDING_DIMENSION, trust_remote_code: Optional[bool] = None, @@ -913,28 +1001,47 @@ def register_default_builders( langs = languages or ["python"] registry.register("bm25", BM25IndexBuilder(languages=langs)) - load_policy = resolve_embedding_load_policy( - embedding_model, - revision=embedding_revision, - trust_remote_code=trust_remote_code, - ) + embedding_provider = normalize_provider(embedding_provider) embedding_kwargs: Dict[str, Any] = {} - if load_policy.trust_remote_code: - embedding_kwargs = {"model_kwargs": {"trust_remote_code": True}} - if embedding_batch_size is not None: - embedding_kwargs["encode_kwargs"] = {"batch_size": embedding_batch_size} - if embedding_max_seq_length is not None: - embedding_kwargs["max_seq_length"] = embedding_max_seq_length - if load_policy.revision is not None: - embedding_kwargs["revision"] = load_policy.revision + embedding_runtime_kwargs: Dict[str, Any] = {} + if embedding_provider == "huggingface": + load_policy = resolve_embedding_load_policy( + embedding_model, + revision=embedding_revision, + trust_remote_code=trust_remote_code, + ) + if load_policy.trust_remote_code: + embedding_kwargs = {"model_kwargs": {"trust_remote_code": True}} + if embedding_batch_size is not None: + embedding_runtime_kwargs["default_batch_size"] = embedding_batch_size + if embedding_max_seq_length is not None: + embedding_kwargs["max_seq_length"] = embedding_max_seq_length + if load_policy.revision is not None: + embedding_kwargs["revision"] = load_policy.revision + elif any( + value is not None + for value in ( + embedding_revision, + trust_remote_code, + embedding_batch_size, + embedding_max_seq_length, + ) + ): + raise ValueError( + "Hugging Face load options cannot be used with a remote embedding provider" + ) registry.register( "vector", VectorIndexBuilder( languages=langs, embedding_model=embedding_model, + embedding_provider=embedding_provider, embedding_dimension=embedding_dimension, embedding_kwargs=embedding_kwargs, + embedding_endpoint=embedding_endpoint, + embedding_credential_env=embedding_credential_env, + embedding_runtime_kwargs=embedding_runtime_kwargs, ), ) registry.register( diff --git a/codenib/compiler/skill_context.py b/codenib/compiler/skill_context.py index 72f87e32..dc3e25af 100644 --- a/codenib/compiler/skill_context.py +++ b/codenib/compiler/skill_context.py @@ -42,6 +42,7 @@ resolve_embedding_load_policy, ) from ..paths import REPO_INDEX_DIRNAME +from ..provider_routes import resolve_embedding_artifact_route from .index_builders import IndexBuilderRegistry, register_default_builders from .index_compiler import IndexCompiler, IndexCompilerConfig from .manifest import RepoManifest @@ -301,6 +302,7 @@ def _load_vector( embedding_revision: Optional[str] = None, index_metric: str = "ip", embedding_kwargs: Optional[Dict[str, Any]] = None, + artifact_metadata: Optional[Dict[str, Any]] = None, trust_remote_code: bool = False, default_batch_size: Optional[int] = None, ): @@ -318,6 +320,7 @@ def _load_vector( embedding_provider=embedding_provider, dimension=embedding_dimension, index_metric=index_metric, + artifact_metadata=artifact_metadata, trust_remote_code=trust_remote_code, **kwargs, ) @@ -560,30 +563,17 @@ def load_contexts_from_manifest( loaded["bm25"] = _load_bm25(manifest.indexes["bm25"].path) if "vector" in needed: vec_entry = manifest.indexes["vector"] - # Prefer the embedding config that was used at build time so the - # load uses a compatible tokenizer/dimension. - emb_model = vec_entry.config.get("embedding_model") - emb_provider = vec_entry.config.get("embedding_provider") - emb_dim = vec_entry.config.get( - "dimension", vec_entry.config.get("embedding_dimension") - ) - embedding_kwargs = vec_entry.config.get("embedding_kwargs") or {} - if not isinstance(embedding_kwargs, dict): - raise ValueError("vector manifest has invalid embedding kwargs") - if ( - not emb_model - or not emb_provider - or not isinstance(emb_dim, int) - or emb_dim <= 0 - ): - raise ValueError("vector manifest has incomplete embedding identity") + route = resolve_embedding_artifact_route(vec_entry.config) + embedding_kwargs = route.embedding_backend_kwargs() + embedding_kwargs.update(route.client_kwargs()) loaded["vector"] = _load_vector( vec_entry.path, - embedding_model=emb_model, - embedding_provider=emb_provider, - embedding_dimension=emb_dim, + embedding_model=route.model, + embedding_provider=route.provider, + embedding_dimension=route.dimension, index_metric=vec_entry.config.get("index_metric", "ip"), embedding_kwargs=embedding_kwargs, + artifact_metadata=vec_entry.config, ) if "symbol_graph" in needed: loaded["symbol_graph"] = _load_symbol_graph( diff --git a/codenib/index/embedding/vector_store.py b/codenib/index/embedding/vector_store.py index 2f9c7e4b..3218760b 100644 --- a/codenib/index/embedding/vector_store.py +++ b/codenib/index/embedding/vector_store.py @@ -21,6 +21,7 @@ from ... import compat_pickle from ...log_utils import get_logger from ...profiler import Profiler +from ...provider_routes import normalize_provider from ...types import NodeInfo logger = get_logger(__name__) @@ -195,18 +196,27 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]: class _OpenAIEmbeddingWrapper: """Wraps the OpenAI SDK to expose ``embed_query`` / ``embed_documents``.""" - def __init__(self, model: str, **kwargs): + def __init__(self, model: str, request_options: Optional[Dict] = None, **kwargs): from openai import OpenAI self._model = model + self._request_options = dict(request_options or {}) self._client = OpenAI(**kwargs) def embed_query(self, text: str) -> List[float]: - resp = self._client.embeddings.create(input=[text], model=self._model) + resp = self._client.embeddings.create( + input=[text], + model=self._model, + **self._request_options, + ) return resp.data[0].embedding def embed_documents(self, texts: List[str]) -> List[List[float]]: - resp = self._client.embeddings.create(input=texts, model=self._model) + resp = self._client.embeddings.create( + input=texts, + model=self._model, + **self._request_options, + ) return [d.embedding for d in sorted(resp.data, key=lambda x: x.index)] @@ -250,7 +260,7 @@ def __init__( Args: embedding_model: Name of the embedding model to use - embedding_provider: Provider for embeddings ("openai", "huggingface") + embedding_provider: Provider for embeddings ("openai" or "huggingface") dimension: Dimension of the embedding vectors index_type: FAISS index type — "flat" (exact brute force, default) or "ivf" (IVF inverted-file; approximate, faster at scale). @@ -1030,10 +1040,25 @@ def load(self, path: Optional[str] = None) -> None: if not config_path.exists(): config_path = load_path / "config.json" + expected_artifact = dict(self.artifact_metadata) if config_path.exists(): with open(config_path, "r") as f: config = json.load(f) + saved_model = config.get("embedding_model") + if saved_model is not None and saved_model != self.embedding_model: + raise ValueError( + f"Vector config model mismatch: expected {self.embedding_model!r}, " + f"found {saved_model!r}" + ) + saved_provider = config.get("embedding_provider") + if saved_provider is not None and normalize_provider( + saved_provider + ) != normalize_provider(self.embedding_provider): + raise ValueError( + "Vector config provider mismatch: expected " + f"{self.embedding_provider!r}, found {saved_provider!r}" + ) saved_dimension = config.get("dimension") if saved_dimension is not None and saved_dimension != self.dimension: raise ValueError( @@ -1050,7 +1075,20 @@ def load(self, path: Optional[str] = None) -> None: self.index_metric = saved_metric saved_artifact = config.get("artifact") if isinstance(saved_artifact, dict): + expected_fingerprint = expected_artifact.get("embedding_fingerprint") + saved_fingerprint = saved_artifact.get("embedding_fingerprint") + if ( + expected_fingerprint is not None + and saved_fingerprint != expected_fingerprint + ): + raise ValueError( + "Vector artifact embedding fingerprint does not match manifest" + ) self.artifact_metadata = dict(saved_artifact) + elif expected_artifact.get("embedding_fingerprint") is not None: + raise ValueError("Vector config is missing embedding artifact identity") + elif expected_artifact.get("embedding_fingerprint") is not None: + raise ValueError("Vector store is missing its top-level configuration") # Load L0 l0_path = load_path / "l0" diff --git a/codenib/mcp/context.py b/codenib/mcp/context.py index ef2a1793..9626ecdd 100644 --- a/codenib/mcp/context.py +++ b/codenib/mcp/context.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Dict, Optional from ..compiler.manifest import RepoManifest +from ..provider_routes import resolve_embedding_artifact_route if TYPE_CHECKING: from ..graph.code_graph import CodeGraph @@ -351,31 +352,20 @@ def _load_vector(self, *, probe: bool = False) -> None: from ..index.embedding.vector_store import CodeVectorStore cfg = entry.config - embedding_model = cfg.get("embedding_model") - embedding_provider = cfg.get("embedding_provider") - dimension = cfg.get("dimension", cfg.get("embedding_dimension")) - embedding_kwargs = cfg.get("embedding_kwargs") or {} - if not isinstance(embedding_kwargs, dict): - raise ValueError("vector manifest has invalid embedding kwargs") - if ( - not embedding_model - or not embedding_provider - or not isinstance(dimension, int) - or dimension <= 0 - ): - raise ValueError("vector manifest has incomplete embedding identity") - - kwargs = dict(embedding_kwargs) + route = resolve_embedding_artifact_route(cfg) + kwargs = route.embedding_backend_kwargs() if probe: - kwargs["embedding"] = _DimensionProbeEmbedding(dimension) + kwargs["embedding"] = _DimensionProbeEmbedding(route.dimension) + else: + kwargs.update(route.client_kwargs()) - # Create CodeVectorStore with embedding model from manifest vector = CodeVectorStore( - embedding_model=embedding_model, - embedding_provider=embedding_provider, - dimension=dimension, + embedding_model=route.model, + embedding_provider=route.provider, + dimension=route.dimension, index_metric=cfg.get("index_metric", "ip"), store_path=entry.path, + artifact_metadata=cfg, **kwargs, ) @@ -384,11 +374,10 @@ def _load_vector(self, *, probe: bool = False) -> None: # Validate model consistency loaded_model = vector.embedding_model - manifest_model = embedding_model - if loaded_model != manifest_model: + if loaded_model != route.model: raise RuntimeError( f"Embedding model mismatch: manifest specifies " - f"{manifest_model!r}, but loaded store has {loaded_model!r}. " + f"{route.model!r}, but loaded store has {loaded_model!r}. " f"Re-run indexing with the correct model." ) @@ -400,7 +389,7 @@ def _load_vector(self, *, probe: bool = False) -> None: "Loaded vector index from %s (%d docs, model=%s)", entry.path, stats["total_documents"], - embedding_model, + route.model, ) except Exception as exc: if vector is not None: diff --git a/codenib/provider_routes.py b/codenib/provider_routes.py new file mode 100644 index 00000000..4404e0b9 --- /dev/null +++ b/codenib/provider_routes.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +"""Secret-free provider identities and process-local inference routes.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any, Literal, Mapping +from urllib.parse import unquote, urlsplit, urlunsplit + +InferenceOperation = Literal["chat", "embeddings"] + +INFERENCE_ROUTE_SCHEMA = "codenib.inference-route.v1" + +_PROVIDER_ALIASES = { + "hugging-face": "huggingface", + "hugging_face": "huggingface", +} +_RETIRED_PROVIDERS = {"github-models", "github_models"} +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_OPERATION_PATH_SUFFIXES = ( + "/chat/completions", + "/completions", + "/embeddings", + "/responses", +) +_SENSITIVE_OPTION_KEYS = { + "apikey", + "authorization", + "authtoken", + "bearertoken", + "clientsecret", + "credential", + "credentials", + "headers", + "password", + "privatekey", + "secret", + "secretkey", + "token", +} +_ENDPOINT_OPTION_KEYS = {"apibase", "baseurl", "endpoint"} +_RUNTIME_TOP_LEVEL_KEYS = { + "batchsize", + "cachefolder", + "defaultbatchsize", + "maxretries", + "retry", + "retries", + "showprogressbar", + "timeout", +} +_RUNTIME_ENCODE_KEYS = { + "batchsize": "batch_size", + "converttotensor": "convert_to_tensor", + "converttonumpy": "convert_to_numpy", + "showprogressbar": "show_progress_bar", +} +_RUNTIME_MODEL_KEYS = { + "cachedir": "cache_dir", + "cachefolder": "cache_folder", + "localfilesonly": "local_files_only", +} +_REMOTE_RUNTIME_KEYS = { + "apikey": "api_key", + "baseurl": "base_url", + "maxretries": "max_retries", + "timeout": "timeout", +} +_HUGGINGFACE_RUNTIME_KEYS = { + "cachefolder": "cache_folder", + "defaultbatchsize": "default_batch_size", +} + + +def normalize_provider(value: str) -> str: + """Return the canonical provider identifier used in persisted metadata.""" + + provider = str(value or "").strip().lower() + if provider in _RETIRED_PROVIDERS: + raise ValueError( + "GitHub Models was retired on 2026-07-30; select a current " + "provider or an OpenAI-compatible BYO endpoint" + ) + provider = _PROVIDER_ALIASES.get(provider, provider) + if not _PROVIDER_RE.fullmatch(provider): + raise ValueError(f"invalid inference provider: {value!r}") + return provider + + +def normalize_endpoint(value: str | None) -> str | None: + """Normalize a provider API base while rejecting credential-bearing URLs.""" + + if value is None or not str(value).strip(): + return None + raw = str(value).strip() + parsed = urlsplit(raw) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + raise ValueError("provider endpoint must be an absolute http(s) URL") + if parsed.username is not None or parsed.password is not None: + raise ValueError("provider endpoint must not contain user information") + if parsed.query or parsed.fragment: + raise ValueError("provider endpoint must not contain a query or fragment") + + decoded_path = unquote(parsed.path) + if any(part in {".", ".."} for part in decoded_path.split("/")): + raise ValueError("provider endpoint must not contain path traversal") + if any(ord(character) < 32 for character in decoded_path): + raise ValueError("provider endpoint contains control characters") + path = parsed.path.rstrip("/") + if path.lower().endswith(_OPERATION_PATH_SUFFIXES) or decoded_path.lower().rstrip( + "/" + ).endswith(_OPERATION_PATH_SUFFIXES): + raise ValueError("provider endpoint must not include an operation path") + + host = parsed.hostname.lower() + host = f"[{host}]" if ":" in host else host + try: + port = parsed.port + except ValueError as exc: + raise ValueError("provider endpoint has an invalid port") from exc + netloc = f"{host}:{port}" if port is not None else host + return urlunsplit((parsed.scheme.lower(), netloc, path, "", "")) + + +def _canonical_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def _json_mapping(value: Mapping[str, Any] | None, *, source: str) -> dict[str, Any]: + if value is not None and not isinstance(value, Mapping): + raise ValueError(f"{source} must be a mapping") + try: + encoded = json.dumps( + dict(value or {}), + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError) as exc: + raise ValueError(f"{source} must contain JSON-compatible values") from exc + return dict(json.loads(encoded)) + + +def _reject_private_options( + value: Any, + *, + source: str, + path: tuple[str, ...] = (), +) -> None: + if isinstance(value, Mapping): + for key, item in value.items(): + canonical = _canonical_key(key) + if canonical in _SENSITIVE_OPTION_KEYS or canonical.endswith( + ("apikey", "accesstoken", "clientsecret") + ): + location = ".".join((*path, key)) + raise ValueError(f"{source} must not contain credentials: {location}") + _reject_private_options( + item, + source=source, + path=(*path, key), + ) + elif isinstance(value, list): + for index, item in enumerate(value): + _reject_private_options( + item, + source=source, + path=(*path, str(index)), + ) + + +def _reject_endpoint_options( + value: Any, + *, + source: str, + path: tuple[str, ...] = (), +) -> None: + if isinstance(value, Mapping): + for key, item in value.items(): + if _canonical_key(key) in _ENDPOINT_OPTION_KEYS: + location = ".".join((*path, key)) + raise ValueError( + f"{source} must use the dedicated endpoint field: {location}" + ) + _reject_endpoint_options(item, source=source, path=(*path, key)) + elif isinstance(value, list): + for index, item in enumerate(value): + _reject_endpoint_options( + item, + source=source, + path=(*path, str(index)), + ) + + +def embedding_compatibility_options( + value: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Return only public options that can affect produced embedding vectors.""" + + options = _json_mapping(value, source="embedding options") + _reject_private_options(options, source="embedding artifact options") + + def visit(value: Any, path: tuple[str, ...]) -> Any: + if isinstance(value, list): + return [ + visit(item, (*path, str(index))) for index, item in enumerate(value) + ] + if not isinstance(value, Mapping): + return value + + result: dict[str, Any] = {} + for key, item in value.items(): + canonical = _canonical_key(key) + if canonical in _ENDPOINT_OPTION_KEYS: + raise ValueError( + f"embedding endpoint must use the dedicated endpoint field: {key}" + ) + + operational = not path and canonical in _RUNTIME_TOP_LEVEL_KEYS + if path and _canonical_key(path[-1]) == "encodekwargs": + operational = operational or canonical in _RUNTIME_ENCODE_KEYS + if path and _canonical_key(path[-1]) == "modelkwargs": + operational = operational or canonical in _RUNTIME_MODEL_KEYS + if operational: + continue + + nested = visit(item, (*path, key)) + if nested not in ({}, []): + result[key] = nested + return result + + return visit(options, ()) + + +def validate_embedding_runtime_options( + value: Mapping[str, Any] | None, + *, + provider: str, +) -> dict[str, Any]: + """Accept only process-local knobs that cannot change vector semantics.""" + + if value is not None and not isinstance(value, Mapping): + raise ValueError("embedding runtime options must be a mapping") + canonical_provider = normalize_provider(provider) + local = canonical_provider == "huggingface" + allowed = _HUGGINGFACE_RUNTIME_KEYS if local else _REMOTE_RUNTIME_KEYS + options = dict(value or {}) + validated: dict[str, Any] = {} + for key, item in options.items(): + canonical = _canonical_key(key) + if canonical in allowed: + validated[allowed[canonical]] = item + continue + if local and canonical == "encodekwargs" and isinstance(item, Mapping): + invalid = [ + nested + for nested in item + if _canonical_key(nested) not in _RUNTIME_ENCODE_KEYS + ] + if invalid: + raise ValueError( + "embedding runtime encode_kwargs may not override vector " + f"semantics: {', '.join(sorted(invalid))}" + ) + validated["encode_kwargs"] = { + _RUNTIME_ENCODE_KEYS[_canonical_key(nested)]: nested_value + for nested, nested_value in item.items() + } + continue + if local and canonical == "modelkwargs" and isinstance(item, Mapping): + invalid = [ + nested + for nested in item + if _canonical_key(nested) not in _RUNTIME_MODEL_KEYS + ] + if invalid: + raise ValueError( + "embedding runtime model_kwargs may not override vector " + f"semantics: {', '.join(sorted(invalid))}" + ) + validated["model_kwargs"] = { + _RUNTIME_MODEL_KEYS[_canonical_key(nested)]: nested_value + for nested, nested_value in item.items() + } + continue + raise ValueError( + "embedding runtime option may affect vector compatibility; " + f"declare it in embedding_kwargs instead: {key}" + ) + return validated + + +def _normalize_model(model: str) -> str: + normalized = str(model or "").strip() + if not normalized or any(character.isspace() for character in normalized): + raise ValueError("inference model must be a non-empty id without whitespace") + if any(ord(character) < 32 for character in normalized): + raise ValueError("inference model contains control characters") + return normalized + + +def _normalize_credential_env(value: str | None) -> str | None: + if value is None or not str(value).strip(): + return None + name = str(value).strip() + if not _ENV_NAME_RE.fullmatch(name): + raise ValueError(f"invalid credential environment variable: {value!r}") + return name + + +@dataclass(frozen=True, slots=True) +class InferenceRoute: + """A public compatibility identity plus a process-local credential source.""" + + operation: InferenceOperation + provider: str + model: str + client_model: str + endpoint: str | None = None + dimension: int | None = None + credential_env: str | None = None + credential_required: bool = False + _options_json: str = field(default="{}", repr=False) + + @property + def compatibility_options(self) -> dict[str, Any]: + return dict(json.loads(self._options_json)) + + def public_identity(self) -> dict[str, Any]: + """Return the complete secret-free identity used for compatibility.""" + + identity: dict[str, Any] = { + "schema": INFERENCE_ROUTE_SCHEMA, + "operation": self.operation, + "provider": self.provider, + "model": self.model, + "endpoint": self.endpoint, + "options": self.compatibility_options, + } + if self.dimension is not None: + identity["dimension"] = self.dimension + return identity + + def embedding_backend_kwargs(self) -> dict[str, Any]: + """Map compatibility options to the selected embedding wrapper.""" + + if self.operation != "embeddings": + raise ValueError("chat routes do not have embedding backend kwargs") + options = self.compatibility_options + if self.provider == "huggingface": + return options + return {"request_options": options} if options else {} + + @property + def compatibility_fingerprint(self) -> str: + payload = json.dumps( + self.public_identity(), + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + def credential(self, environ: Mapping[str, str] | None = None) -> str | None: + """Resolve the selected credential without storing it on the route.""" + + environment = os.environ if environ is None else environ + value = environment.get(self.credential_env, "") if self.credential_env else "" + if value: + return value + if self.credential_required: + name = self.credential_env or "an explicit credential variable" + raise ValueError( + f"credential environment variable is unset or empty: {name}" + ) + return None + + def client_kwargs( + self, + environ: Mapping[str, str] | None = None, + ) -> dict[str, str]: + """Resolve process-local SDK kwargs; callers must never persist this value.""" + + result: dict[str, str] = {} + if self.endpoint: + key = "api_base" if self.operation == "chat" else "base_url" + result[key] = self.endpoint + credential = self.credential(environ) + if credential: + result["api_key"] = credential + return result + + +def resolve_inference_route( + *, + operation: InferenceOperation, + provider: str, + model: str, + endpoint: str | None = None, + dimension: int | None = None, + credential_env: str | None = None, + compatibility_options: Mapping[str, Any] | None = None, + environ: Mapping[str, str] | None = None, +) -> InferenceRoute: + """Resolve an explicit provider route without importing an inference SDK.""" + + if operation not in {"chat", "embeddings"}: + raise ValueError(f"unsupported inference operation: {operation!r}") + canonical_provider = normalize_provider(provider) + canonical_model = _normalize_model(model) + normalized_endpoint = normalize_endpoint(endpoint) + environment = os.environ if environ is None else environ + selected_env = _normalize_credential_env(credential_env) + credential_was_explicit = selected_env is not None + + if canonical_provider == "huggingface": + if operation != "embeddings": + raise ValueError("huggingface is supported only for embeddings") + if normalized_endpoint or selected_env: + raise ValueError( + "local Hugging Face embeddings do not accept an endpoint or API key" + ) + client_model = canonical_model + credential_required = False + elif canonical_provider == "openai": + if selected_env is None and environment.get("OPENAI_API_KEY"): + selected_env = "OPENAI_API_KEY" + if selected_env is None and normalized_endpoint is None: + selected_env = "OPENAI_API_KEY" + client_model = ( + canonical_model + if operation == "embeddings" or canonical_model.startswith("openai/") + else f"openai/{canonical_model}" + ) + credential_required = normalized_endpoint is None or credential_was_explicit + else: + if operation == "embeddings": + raise ValueError( + f"unsupported embedding provider: {canonical_provider}; use " + "huggingface or openai" + ) + client_model = ( + canonical_model + if canonical_model.startswith(f"{canonical_provider}/") + else f"{canonical_provider}/{canonical_model}" + ) + credential_required = credential_was_explicit + + if operation == "embeddings": + if ( + not isinstance(dimension, int) + or isinstance(dimension, bool) + or dimension <= 0 + ): + raise ValueError("embedding dimension must be a positive integer") + options = embedding_compatibility_options(compatibility_options) + if canonical_provider == "openai": + unsupported = sorted(set(options) - {"dimensions"}) + if unsupported: + raise ValueError( + "OpenAI-compatible embedding routes support only the dimensions " + f"compatibility option, not: {', '.join(unsupported)}" + ) + requested_dimension = options.get("dimensions") + if requested_dimension is not None and requested_dimension != dimension: + raise ValueError( + "embedding dimensions option must equal the artifact dimension" + ) + else: + if dimension is not None: + raise ValueError("chat routes do not accept an embedding dimension") + options = _json_mapping( + compatibility_options, + source="chat compatibility options", + ) + _reject_private_options(options, source="chat compatibility options") + _reject_endpoint_options(options, source="chat compatibility options") + options_json = json.dumps( + options, + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + return InferenceRoute( + operation=operation, + provider=canonical_provider, + model=canonical_model, + client_model=client_model, + endpoint=normalized_endpoint, + dimension=dimension, + credential_env=selected_env, + credential_required=credential_required, + _options_json=options_json, + ) + + +def resolve_embedding_artifact_route( + config: Mapping[str, Any], + *, + credential_env: str | None = None, + environ: Mapping[str, str] | None = None, +) -> InferenceRoute: + """Resolve and validate the immutable embedding route in an artifact. + + Schema-v3 artifacts carry a canonical route and fingerprint. Older + artifacts are reconstructed from their top-level embedding fields, while + still rejecting credentials or endpoints hidden in ``embedding_kwargs``. + """ + + artifact = _json_mapping(config, source="vector artifact configuration") + builder_schema = artifact.get("builder_schema") + route_identity_required = ( + isinstance(builder_schema, int) + and not isinstance(builder_schema, bool) + and builder_schema >= 3 + ) + embedded = artifact.get("embedding_route") + + if route_identity_required and not isinstance(embedded, Mapping): + raise ValueError("vector artifact is missing its embedding route identity") + + if embedded is not None: + if not isinstance(embedded, Mapping): + raise ValueError("vector artifact has an invalid embedding route identity") + embedded_route = _json_mapping( + embedded, + source="vector artifact embedding route", + ) + if embedded_route.get("schema") != INFERENCE_ROUTE_SCHEMA: + raise ValueError( + "vector artifact uses an unsupported embedding route schema" + ) + if embedded_route.get("operation") != "embeddings": + raise ValueError("vector artifact route is not an embedding route") + route = resolve_inference_route( + operation="embeddings", + provider=embedded_route.get("provider", ""), + model=embedded_route.get("model", ""), + endpoint=embedded_route.get("endpoint"), + dimension=embedded_route.get("dimension"), + credential_env=credential_env, + compatibility_options=embedded_route.get("options"), + environ=environ, + ) + if embedded_route != route.public_identity(): + raise ValueError("vector artifact embedding route is not canonical") + else: + route = resolve_inference_route( + operation="embeddings", + provider=artifact.get("embedding_provider", ""), + model=artifact.get("embedding_model", ""), + endpoint=artifact.get("embedding_endpoint"), + dimension=artifact.get("dimension", artifact.get("embedding_dimension")), + credential_env=credential_env, + compatibility_options=artifact.get("embedding_kwargs"), + environ=environ, + ) + + top_level_checks = { + "embedding_model": route.model, + "embedding_provider": route.provider, + "embedding_dimension": route.dimension, + "dimension": route.dimension, + "embedding_endpoint": route.endpoint, + } + if route_identity_required: + top_level_checks["embedding_kwargs"] = route.compatibility_options + for key, expected in top_level_checks.items(): + if key not in artifact: + if route_identity_required: + raise ValueError(f"vector artifact is missing {key}") + continue + actual = artifact[key] + if key == "embedding_provider" and isinstance(actual, str): + actual = normalize_provider(actual) + elif key == "embedding_endpoint": + actual = normalize_endpoint(actual) + elif key in {"embedding_dimension", "dimension"} and ( + not isinstance(actual, int) or isinstance(actual, bool) + ): + raise ValueError(f"vector artifact {key} is not a positive integer") + if actual != expected: + raise ValueError(f"vector artifact {key} disagrees with its route identity") + + fingerprint = artifact.get("embedding_fingerprint") + if route_identity_required and not isinstance(fingerprint, str): + raise ValueError("vector artifact is missing its embedding fingerprint") + if fingerprint is not None and fingerprint != route.compatibility_fingerprint: + raise ValueError("vector artifact embedding fingerprint mismatch") + return route + + +__all__ = [ + "INFERENCE_ROUTE_SCHEMA", + "InferenceRoute", + "embedding_compatibility_options", + "normalize_endpoint", + "normalize_provider", + "resolve_embedding_artifact_route", + "resolve_inference_route", + "validate_embedding_runtime_options", +] diff --git a/codenib/web/repo_registry.py b/codenib/web/repo_registry.py index 16860200..fe3e96e0 100644 --- a/codenib/web/repo_registry.py +++ b/codenib/web/repo_registry.py @@ -13,7 +13,6 @@ from __future__ import annotations -import json import os from dataclasses import dataclass from importlib.util import find_spec @@ -22,6 +21,7 @@ from ..compiler.manifest import RepoManifest from ..log_utils import get_logger +from ..provider_routes import normalize_endpoint, resolve_embedding_artifact_route from ..repository_summary import read_repository_summary from .config import QAConfig, RepoEntry, load_registry from .schemas import GraphCoverage, RepoInfo @@ -395,60 +395,57 @@ def _load_repo_metadata(self, entry: RepoEntry) -> RepoBundle: def _load_vector_store(self, vec_entry: Any) -> "CodeVectorStore": """Load a manifest vector view with the configured embedding backend.""" - artifact_config = vec_entry.config or {} - emb_model = artifact_config.get("embedding_model", self._config.embedding_model) - emb_dim = artifact_config.get( - "dimension", - artifact_config.get( - "embedding_dimension", - self._config.embedding_dimension, - ), - ) - if not isinstance(emb_model, str) or not emb_model.strip(): - raise ValueError("vector manifest has invalid embedding model") - if not isinstance(emb_dim, int) or emb_dim <= 0: - raise ValueError("vector manifest has invalid embedding dimension") - - provider = self._config.embedding_provider - artifact_provider = artifact_config.get("embedding_provider") - embedding_kwargs = artifact_config.get("embedding_kwargs") or {} - if not isinstance(embedding_kwargs, dict): - raise ValueError("vector manifest has invalid embedding kwargs") - if artifact_provider and artifact_provider != provider: - # A remote provider may serve vectors compatible with an artifact - # built in-process, but Hugging Face constructor options are not - # valid OpenAI client options. - embedding_kwargs = {} - else: - embedding_kwargs = dict(embedding_kwargs) - - config_fingerprint = json.dumps( - embedding_kwargs, - sort_keys=True, - separators=(",", ":"), - default=repr, - ) + artifact_config = dict(vec_entry.config or {}) + if not artifact_config.get("embedding_route") and not artifact_config.get( + "embedding_provider" + ): + # Pre-provider manifests from build_qa_index.py intentionally relied + # on the QA runtime route. Keep that narrow compatibility path while + # requiring all newly built artifacts to persist their provider. + artifact_config["embedding_provider"] = self._config.embedding_provider + if self._config.embedding_base_url: + artifact_config["embedding_endpoint"] = self._config.embedding_base_url + logger.warning( + "Vector artifact at %s has no embedding provider; using the " + "configured legacy route %s. Rebuild the manifest to persist " + "its compatibility identity.", + vec_entry.path, + self._config.embedding_provider, + ) + route = resolve_embedding_artifact_route(artifact_config) + configured_endpoint = normalize_endpoint(self._config.embedding_base_url) + if configured_endpoint is not None and configured_endpoint != route.endpoint: + raise ValueError( + "configured embedding endpoint does not match the vector artifact" + ) cache_key = ( - provider, - emb_model, - emb_dim, - self._config.embedding_base_url, - config_fingerprint, + route.provider, + route.model, + route.dimension, + route.compatibility_fingerprint, ) - client_kwargs: Dict[str, object] = {} - if self._config.embedding_base_url: - client_kwargs["base_url"] = self._config.embedding_base_url + embedding_kwargs = route.embedding_backend_kwargs() if self._config.embedding_api_key: + if route.provider == "huggingface": + raise ValueError( + "an embedding API key cannot reopen a local Hugging Face artifact" + ) + client_kwargs: Dict[str, object] = {} + if route.endpoint: + client_kwargs["base_url"] = route.endpoint client_kwargs["api_key"] = self._config.embedding_api_key + else: + client_kwargs = route.client_kwargs() embedding_kwargs.update(client_kwargs) vector_store = _vector_store_type()( - embedding_model=emb_model, - embedding_provider=provider, - dimension=emb_dim, + embedding_model=route.model, + embedding_provider=route.provider, + dimension=route.dimension, index_metric=artifact_config.get("index_metric", "ip"), store_path=vec_entry.path, embedding=self._embeddings.get(cache_key), + artifact_metadata=artifact_config, **embedding_kwargs, ) self._embeddings[cache_key] = vector_store.embedding diff --git a/scripts/build_qa_index.py b/scripts/build_qa_index.py index c2286b62..9c77964b 100644 --- a/scripts/build_qa_index.py +++ b/scripts/build_qa_index.py @@ -236,18 +236,23 @@ def build_one_prebuilt(cfg, row, force: bool) -> RepoEntry: ) # Vector index entry points at the pre-built dir. CodeVectorStore.load() # reads /config_.json + /{l0,l2}/{index,documents}_.* + vector_config = { + "embedding_model": cfg.embedding_model, + "embedding_provider": cfg.embedding_provider, + "embedding_dimension": cfg.embedding_dimension, + } + if cfg.embedding_base_url: + vector_config["embedding_endpoint"] = cfg.embedding_base_url manifest.indexes["vector"] = IndexEntry( index_type="vector", path=inst_dir, built_at=now.isoformat(), built_at_epoch=now.timestamp(), status="fresh", - config={ - "embedding_model": cfg.embedding_model, - "embedding_dimension": cfg.embedding_dimension, - }, + config=vector_config, metadata={ "embedding_model": cfg.embedding_model, + "embedding_provider": cfg.embedding_provider, "levels": ["l0", "l2"], "source": "prebuilt", }, diff --git a/test/compiler/test_index_compiler.py b/test/compiler/test_index_compiler.py index 792a4b5e..275b5a92 100644 --- a/test/compiler/test_index_compiler.py +++ b/test/compiler/test_index_compiler.py @@ -196,13 +196,25 @@ def test_artifact_identity_is_shared_by_full_and_incremental_statuses(self): index_metric="l2", ) - assert builder.artifact_identity() == { - "builder_schema": 2, + identity = builder.artifact_identity() + assert identity == { + "builder_schema": 3, "embedding_model": "test-model", "embedding_provider": "huggingface", "embedding_dimension": 384, "dimension": 384, + "embedding_endpoint": None, "embedding_kwargs": {"revision": "model-commit"}, + "embedding_route": { + "schema": "codenib.inference-route.v1", + "operation": "embeddings", + "provider": "huggingface", + "model": "test-model", + "endpoint": None, + "dimension": 384, + "options": {"revision": "model-commit"}, + }, + "embedding_fingerprint": identity["embedding_fingerprint"], "index_metric": "l2", "languages": ["python"], "levels": ["l0", "l2"], @@ -210,6 +222,83 @@ def test_artifact_identity_is_shared_by_full_and_incremental_statuses(self): "repository_filter_policy": REPOSITORY_FILTER_POLICY_VERSION, } + def test_runtime_options_do_not_change_identity_or_appear_in_repr(self): + first = VectorIndexBuilder( + embedding_model="text-embedding-3-small", + embedding_provider="openai", + embedding_endpoint="https://inference.example.test/v1", + embedding_dimension=1536, + embedding_runtime_kwargs={"api_key": "first-secret", "timeout": 10}, + ) + second = VectorIndexBuilder( + embedding_model="text-embedding-3-small", + embedding_provider="openai", + embedding_endpoint="https://inference.example.test/v1", + embedding_dimension=1536, + embedding_runtime_kwargs={"api_key": "second-secret", "timeout": 60}, + ) + + assert first.artifact_identity() == second.artifact_identity() + assert "first-secret" not in repr(first) + + def test_runtime_options_cannot_override_embedding_semantics(self): + builder = VectorIndexBuilder( + embedding_runtime_kwargs={"query_prompt": "different"}, + ) + + with pytest.raises(ValueError, match="vector compatibility"): + builder._embedding_call_kwargs() + + @patch("codenib.index.embedding.builders.build_hierarchical_vector_store") + def test_build_rejects_provider_dimension_mismatch(self, mock_build_fn, tmp_path): + mock_build_fn.return_value = SimpleNamespace( + dimension=1024, + l0_documents=[], + l2_documents=["doc"], + ) + builder = VectorIndexBuilder( + embedding_model="test-model", + embedding_dimension=768, + ) + + with pytest.raises(ValueError, match="returned dimension 1024, expected 768"): + builder.build( + scope="current_repo", + repo_path="/fake/repo", + output_dir=str(tmp_path / "vector"), + ) + + @patch("codenib.index.embedding.builders.build_hierarchical_vector_store") + def test_remote_runtime_secret_is_not_persisted(self, mock_build_fn, tmp_path): + mock_vs = MagicMock(l0_documents=[], l2_documents=["doc"]) + mock_build_fn.return_value = mock_vs + builder = VectorIndexBuilder( + embedding_model="text-embedding-3-small", + embedding_provider="openai", + embedding_endpoint="https://inference.example.test/v1", + embedding_dimension=1536, + embedding_credential_env="MODELS_TOKEN", + embedding_runtime_kwargs={"api_key": "runtime-secret", "timeout": 10}, + ) + + status = builder.build( + scope="current_repo", + repo_path="/fake/repo", + output_dir=str(tmp_path / "vector"), + ) + + serialized = json.dumps(status.metadata, sort_keys=True) + assert "runtime-secret" not in serialized + assert "MODELS_TOKEN" not in serialized + assert status.metadata["embedding_endpoint"] == ( + "https://inference.example.test/v1" + ) + call = mock_build_fn.call_args.kwargs + assert call["embedding_kwargs"]["api_key"] == "runtime-secret" + assert call["embedding_kwargs"]["base_url"] == ( + "https://inference.example.test/v1" + ) + # --------------------------------------------------------------------------- # SymbolGraphBuilder @@ -600,6 +689,7 @@ def test_registers_all_defaults(self): "model_kwargs": {"trust_remote_code": True}, "revision": DEFAULT_EMBEDDING_REVISION, } + assert vector.embedding_runtime_kwargs == {} def test_custom_params_forwarded(self): registry = IndexBuilderRegistry() @@ -623,10 +713,10 @@ def test_custom_params_forwarded(self): assert vector.embedding_model == "custom-model" assert vector.embedding_dimension == 512 assert vector.embedding_kwargs == { - "encode_kwargs": {"batch_size": 4}, "max_seq_length": 8192, "revision": "model-commit", } + assert vector.embedding_runtime_kwargs == {"default_batch_size": 4} symbol_graph = registry.get("symbol_graph") assert isinstance(symbol_graph, SymbolGraphBuilder) @@ -637,6 +727,19 @@ def test_custom_params_forwarded(self): assert symbol_graph.allow_partial_index is False assert symbol_graph.source_coverage_fallback is False + def test_provider_alias_is_normalized_before_model_policy(self): + registry = IndexBuilderRegistry() + register_default_builders( + registry, + languages=["python"], + embedding_provider="HUGGING-FACE", + ) + + vector = registry.get("vector") + assert isinstance(vector, VectorIndexBuilder) + assert vector.embedding_provider == "huggingface" + assert vector.embedding_kwargs["revision"] == DEFAULT_EMBEDDING_REVISION + def test_can_register_partial_multi_language_graph_builder(self): registry = IndexBuilderRegistry() register_default_builders( diff --git a/test/compiler/test_skill_context.py b/test/compiler/test_skill_context.py index a21de2a3..32222956 100644 --- a/test/compiler/test_skill_context.py +++ b/test/compiler/test_skill_context.py @@ -385,10 +385,10 @@ def test_embedding_resource_limits_reach_builder_and_loader( vector_builder = mocked_build["registries"][0].get("vector") assert vector_builder.embedding_kwargs == { - "encode_kwargs": {"batch_size": 4}, "max_seq_length": 8192, "revision": "model-revision", } + assert vector_builder.embedding_runtime_kwargs == {"default_batch_size": 4} assert mocked_build["vector_kwargs"] == [ { "embedding_model": "nomic-ai/CodeRankEmbed", diff --git a/test/index/test_vector_artifact_identity.py b/test/index/test_vector_artifact_identity.py new file mode 100644 index 00000000..aa810932 --- /dev/null +++ b/test/index/test_vector_artifact_identity.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from codenib.index.embedding.vector_store import ( + CodeVectorStore, + _OpenAIEmbeddingWrapper, +) + + +class _Embedding: + def __init__(self, dimension: int) -> None: + self.dimension = dimension + + def embed_query(self, _text: str) -> list[float]: + return [0.0] * self.dimension + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [[0.0] * self.dimension for _ in texts] + + +def _store(path, *, fingerprint: str, provider: str = "huggingface"): + return CodeVectorStore( + embedding_model="vendor/model", + embedding_provider=provider, + dimension=4, + store_path=str(path), + embedding=_Embedding(4), + artifact_metadata={"embedding_fingerprint": fingerprint}, + ) + + +def test_load_rejects_manifest_and_saved_artifact_fingerprint_mismatch( + tmp_path, +) -> None: + _store(tmp_path, fingerprint="sha256:first").save() + reopened = _store(tmp_path, fingerprint="sha256:second") + + with pytest.raises(ValueError, match="does not match manifest"): + reopened.load() + + +def test_load_rejects_provider_substitution(tmp_path) -> None: + _store(tmp_path, fingerprint="sha256:same").save() + reopened = _store( + tmp_path, + fingerprint="sha256:same", + provider="openai", + ) + + with pytest.raises(ValueError, match="provider mismatch"): + reopened.load() + + +def test_load_requires_saved_identity_when_manifest_has_a_fingerprint(tmp_path) -> None: + reopened = _store(tmp_path, fingerprint="sha256:expected") + + with pytest.raises(ValueError, match="top-level configuration"): + reopened.load() + + +def test_openai_wrapper_sends_vector_options_on_embedding_requests() -> None: + client = MagicMock() + client.embeddings.create.return_value = SimpleNamespace( + data=[SimpleNamespace(index=0, embedding=[1.0, 2.0])] + ) + with patch("openai.OpenAI", return_value=client) as factory: + embedding = _OpenAIEmbeddingWrapper( + "text-embedding-3-small", + request_options={"dimensions": 2}, + api_key="runtime-secret", + ) + assert embedding.embed_query("query") == [1.0, 2.0] + + factory.assert_called_once_with(api_key="runtime-secret") + client.embeddings.create.assert_called_once_with( + input=["query"], + model="text-embedding-3-small", + dimensions=2, + ) diff --git a/test/mcp_server/test_context.py b/test/mcp_server/test_context.py index 62a70b63..faa791bf 100644 --- a/test/mcp_server/test_context.py +++ b/test/mcp_server/test_context.py @@ -14,6 +14,7 @@ from codenib.compiler.manifest import IndexEntry, RepoManifest from codenib.mcp.context import RUNTIME_VIEW_NAMES, ServerContext +from codenib.provider_routes import resolve_inference_route @pytest.fixture() @@ -241,6 +242,16 @@ def test_load_vector_accepts_compiler_manifest_identity(tmp_path: Path) -> None: dimension=384, index_metric="ip", store_path=str(vector_dir), + artifact_metadata={ + "embedding_model": "test-model", + "embedding_provider": "huggingface", + "embedding_dimension": 384, + "embedding_kwargs": { + "max_seq_length": 8192, + "revision": "model-commit", + }, + "index_metric": "ip", + }, max_seq_length=8192, revision="model-commit", ) @@ -283,6 +294,116 @@ def test_validate_views_probes_vector_without_loading_embedding_model( assert errors == {} assert cls.call_args.kwargs["embedding"].dimension == 384 + + +def test_load_vector_rebinds_openai_credential_without_persisting_it( + tmp_path: Path, + monkeypatch, +) -> None: + vector_dir = tmp_path / "vector" + vector_dir.mkdir() + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + dimension=1536, + environ={}, + ) + config = { + "builder_schema": 3, + "embedding_model": route.model, + "embedding_provider": route.provider, + "embedding_dimension": route.dimension, + "dimension": route.dimension, + "embedding_endpoint": route.endpoint, + "embedding_kwargs": route.compatibility_options, + "embedding_route": route.public_identity(), + "embedding_fingerprint": route.compatibility_fingerprint, + } + manifest = RepoManifest( + repo_path=str(tmp_path), + indexes={ + "vector": IndexEntry( + index_type="vector", + path=str(vector_dir), + built_at="2026-01-01T00:00:00", + built_at_epoch=0.0, + status="fresh", + config=config, + ), + }, + ) + manifest.save(tmp_path / "repo_manifest.json") + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") + + vector = MagicMock() + vector.embedding_model = route.model + vector.get_stats.return_value = {"total_documents": 3} + with patch( + "codenib.index.embedding.vector_store.CodeVectorStore", + return_value=vector, + ) as cls: + ctx = ServerContext.load(tmp_path / "repo_manifest.json") + + kwargs = cls.call_args.kwargs + assert kwargs["embedding_provider"] == "openai" + assert "base_url" not in kwargs + assert kwargs["api_key"] == "runtime-secret" + assert "runtime-secret" not in json.dumps(config) + assert ctx.vector is vector + + +def test_validate_views_does_not_require_remote_embedding_credentials( + tmp_path: Path, + monkeypatch, +) -> None: + vector_dir = tmp_path / "vector" + vector_dir.mkdir() + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + dimension=1536, + environ={}, + ) + config = { + "builder_schema": 3, + "embedding_model": route.model, + "embedding_provider": route.provider, + "embedding_dimension": route.dimension, + "dimension": route.dimension, + "embedding_endpoint": route.endpoint, + "embedding_kwargs": {}, + "embedding_route": route.public_identity(), + "embedding_fingerprint": route.compatibility_fingerprint, + } + manifest = RepoManifest( + repo_path=str(tmp_path), + indexes={ + "vector": IndexEntry( + index_type="vector", + path=str(vector_dir), + built_at="2026-01-01T00:00:00", + built_at_epoch=0.0, + status="fresh", + config=config, + ), + }, + ) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + vector = MagicMock() + vector.embedding_model = route.model + vector.get_stats.return_value = {"total_documents": 3} + + with patch( + "codenib.index.embedding.vector_store.CodeVectorStore", + return_value=vector, + ) as cls: + errors = ServerContext.validate_views(manifest, views={"vector"}) + + assert errors == {} + assert "api_key" not in cls.call_args.kwargs + assert cls.call_args.kwargs["embedding"].dimension == 1536 vector.load.assert_called_once_with() vector.close.assert_called_once_with() diff --git a/test/test_provider_routes.py b/test/test_provider_routes.py new file mode 100644 index 00000000..24c6d94d --- /dev/null +++ b/test/test_provider_routes.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json + +import pytest + +from codenib.provider_routes import ( + embedding_compatibility_options, + normalize_endpoint, + resolve_embedding_artifact_route, + resolve_inference_route, + validate_embedding_runtime_options, +) + + +def test_openai_route_uses_explicit_token_without_storing_it() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + endpoint="https://inference.example.test/v1", + dimension=1536, + credential_env="MODELS_TOKEN", + environ={"MODELS_TOKEN": "action-secret"}, + ) + + assert route.provider == "openai" + assert route.endpoint == "https://inference.example.test/v1" + assert route.credential_env == "MODELS_TOKEN" + assert route.client_kwargs({"MODELS_TOKEN": "action-secret"}) == { + "base_url": "https://inference.example.test/v1", + "api_key": "action-secret", + } + serialized = json.dumps(route.public_identity(), sort_keys=True) + assert "action-secret" not in serialized + assert "MODELS_TOKEN" not in serialized + + +def test_openai_route_uses_explicit_credential_then_default() -> None: + explicit = resolve_inference_route( + operation="chat", + provider="openai", + model="gpt-4.1", + credential_env="MODELS_TOKEN", + environ={"OPENAI_API_KEY": "default", "MODELS_TOKEN": "explicit"}, + ) + fallback = resolve_inference_route( + operation="chat", + provider="openai", + model="gpt-4.1", + environ={"OPENAI_API_KEY": "default"}, + ) + + assert explicit.client_model == "openai/gpt-4.1" + assert explicit.credential_env == "MODELS_TOKEN" + assert explicit.credential({"MODELS_TOKEN": "explicit"}) == "explicit" + assert fallback.credential_env == "OPENAI_API_KEY" + + +def test_openai_route_reports_missing_credentials_without_token_data() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + dimension=1536, + environ={}, + ) + + with pytest.raises(ValueError, match="OPENAI_API_KEY") as error: + route.client_kwargs({}) + assert "Bearer" not in str(error.value) + + +@pytest.mark.parametrize("provider", ["github-models", "github_models"]) +def test_retired_github_models_route_is_rejected(provider: str) -> None: + with pytest.raises(ValueError, match="retired on 2026-07-30"): + resolve_inference_route( + operation="chat", + provider=provider, + model="openai/gpt-4.1", + ) + + +@pytest.mark.parametrize( + "value", + [ + "inference.example.test/v1", + "https://token@inference.example.test/v1", + "https://inference.example.test/v1?api_key=secret", + "https://inference.example.test/v1#fragment", + "https://inference.example.test/v1/../other", + "https://inference.example.test/v1/embeddings", + "https://inference.example.test/v1/%65mbeddings", + ], +) +def test_endpoint_normalization_rejects_unsafe_or_operation_urls(value: str) -> None: + with pytest.raises(ValueError): + normalize_endpoint(value) + + +def test_endpoint_normalization_is_stable() -> None: + assert ( + normalize_endpoint("HTTPS://INFERENCE.EXAMPLE.TEST/v1/") + == "https://inference.example.test/v1" + ) + + +def test_embedding_fingerprint_tracks_vector_semantics_not_runtime_knobs() -> None: + base = dict( + operation="embeddings", + provider="huggingface", + model="model/revision", + dimension=768, + ) + route = resolve_inference_route( + **base, + compatibility_options={ + "revision": "abc", + "max_seq_length": 2048, + "encode_kwargs": { + "batch_size": 4, + "normalize_embeddings": True, + }, + }, + ) + other_batch = resolve_inference_route( + **base, + compatibility_options={ + "revision": "abc", + "max_seq_length": 2048, + "encode_kwargs": { + "batch_size": 64, + "normalize_embeddings": True, + }, + }, + ) + different_prompt = resolve_inference_route( + **base, + compatibility_options={ + "revision": "abc", + "max_seq_length": 2048, + "query_prompt": "Represent this query: ", + "encode_kwargs": {"normalize_embeddings": True}, + }, + ) + + assert route.compatibility_fingerprint == other_batch.compatibility_fingerprint + assert route.compatibility_fingerprint != different_prompt.compatibility_fingerprint + assert route.compatibility_options["encode_kwargs"] == { + "normalize_embeddings": True + } + + +def test_embedding_fingerprint_tracks_execution_device() -> None: + cpu = resolve_inference_route( + operation="embeddings", + provider="huggingface", + model="vendor/model", + dimension=768, + compatibility_options={"encode_kwargs": {"device": "cpu"}}, + ) + cuda = resolve_inference_route( + operation="embeddings", + provider="huggingface", + model="vendor/model", + dimension=768, + compatibility_options={"encode_kwargs": {"device": "cuda"}}, + ) + + assert cpu.compatibility_fingerprint != cuda.compatibility_fingerprint + + +@pytest.mark.parametrize( + "options", + [ + {"api_key": "secret"}, + {"headers": {"Authorization": "Bearer secret"}}, + {"model_kwargs": {"access_token": "secret"}}, + {"fallbacks": [{"api_key": "secret"}]}, + {"base_url": "https://example.test/v1"}, + ], +) +def test_embedding_artifact_options_reject_credentials_and_endpoints(options) -> None: + with pytest.raises(ValueError): + embedding_compatibility_options(options) + + +def test_chat_compatibility_options_reject_credentials() -> None: + with pytest.raises(ValueError, match="must not contain credentials"): + resolve_inference_route( + operation="chat", + provider="openai", + model="gpt-4.1", + compatibility_options={"headers": {"Authorization": "Bearer secret"}}, + ) + + with pytest.raises(ValueError, match="dedicated endpoint"): + resolve_inference_route( + operation="chat", + provider="openai", + model="gpt-4.1", + compatibility_options={"api_base": "https://example.test/v1"}, + ) + + +def test_openai_custom_endpoint_can_be_intentionally_unauthenticated() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="served-embedding", + endpoint="http://localhost:8080/v1/", + dimension=1024, + environ={}, + ) + + assert route.client_kwargs({}) == {"base_url": "http://localhost:8080/v1"} + + +def test_openai_embedding_dimensions_are_request_options() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + dimension=512, + compatibility_options={"dimensions": 512}, + ) + + assert route.embedding_backend_kwargs() == {"request_options": {"dimensions": 512}} + with pytest.raises(ValueError, match="must equal"): + resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + dimension=512, + compatibility_options={"dimensions": 256}, + ) + + +def test_huggingface_route_rejects_remote_configuration() -> None: + with pytest.raises(ValueError, match="local Hugging Face"): + resolve_inference_route( + operation="embeddings", + provider="huggingface", + model="nomic-ai/CodeRankEmbed", + endpoint="https://example.test/v1", + dimension=768, + ) + + +def test_runtime_options_are_provider_specific_and_cannot_override_semantics() -> None: + assert validate_embedding_runtime_options( + {"api-key": "secret", "max-retries": 2}, + provider="openai", + ) == {"api_key": "secret", "max_retries": 2} + assert validate_embedding_runtime_options( + {"encode_kwargs": {"batch-size": 8}}, + provider="huggingface", + ) == {"encode_kwargs": {"batch_size": 8}} + + with pytest.raises(ValueError, match="vector compatibility"): + validate_embedding_runtime_options( + {"query_prompt": "changed"}, + provider="huggingface", + ) + with pytest.raises(ValueError, match="vector semantics"): + validate_embedding_runtime_options( + {"encode_kwargs": {"normalize_embeddings": False}}, + provider="huggingface", + ) + + +def test_schema_v3_artifact_route_round_trips_and_rejects_drift() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="openai", + model="text-embedding-3-small", + endpoint="https://inference.example.test/v1", + dimension=1536, + environ={}, + ) + artifact = { + "builder_schema": 3, + "embedding_model": route.model, + "embedding_provider": route.provider, + "embedding_dimension": route.dimension, + "dimension": route.dimension, + "embedding_endpoint": route.endpoint, + "embedding_kwargs": route.compatibility_options, + "embedding_route": route.public_identity(), + "embedding_fingerprint": route.compatibility_fingerprint, + } + + reopened = resolve_embedding_artifact_route(artifact, environ={}) + assert reopened.public_identity() == route.public_identity() + + drifted = json.loads(json.dumps(artifact)) + drifted["embedding_route"]["model"] = "openai/text-embedding-3-large" + with pytest.raises(ValueError, match="disagrees|fingerprint"): + resolve_embedding_artifact_route(drifted, environ={}) + + +def test_legacy_artifact_route_is_supported_but_private_kwargs_are_not() -> None: + route = resolve_embedding_artifact_route( + { + "builder_schema": 2, + "embedding_model": "vendor/model", + "embedding_provider": "huggingface", + "embedding_dimension": 384, + "embedding_kwargs": { + "revision": "immutable", + "encode_kwargs": {"batch_size": 16}, + }, + } + ) + assert route.model == "vendor/model" + assert route.compatibility_options == {"revision": "immutable"} + + with pytest.raises(ValueError, match="credentials"): + resolve_embedding_artifact_route( + { + "embedding_model": "served-model", + "embedding_provider": "openai", + "embedding_dimension": 384, + "embedding_kwargs": {"api_key": "persisted-secret"}, + } + ) diff --git a/test/web/test_repo_registry.py b/test/web/test_repo_registry.py index e83f728a..ce8dc124 100644 --- a/test/web/test_repo_registry.py +++ b/test/web/test_repo_registry.py @@ -345,7 +345,15 @@ def load(self, path): embedding_api_key="secret", ) registry = RepoRegistry(cfg) - entry = SimpleNamespace(path="/tmp/vector", config={}) + entry = SimpleNamespace( + path="/tmp/vector", + config={ + "embedding_model": "embed-model", + "embedding_provider": "openai", + "embedding_dimension": 768, + "embedding_endpoint": "http://embed.local/v1", + }, + ) first = registry._load_vector_store(entry) second = registry._load_vector_store(entry) @@ -398,6 +406,38 @@ def load(self, _path): assert created[0].kwargs["revision"] == "immutable-model-revision" +def test_vector_store_supports_legacy_prebuilt_provider_fallback(monkeypatch): + created = [] + + class FakeVectorStore: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.embedding = object() + created.append(self) + + def load(self, _path): + pass + + monkeypatch.setattr( + "codenib.web.repo_registry._vector_store_type", + lambda: FakeVectorStore, + ) + registry = RepoRegistry(QAConfig(embedding_provider="huggingface")) + entry = SimpleNamespace( + path="/tmp/legacy-prebuilt", + config={ + "embedding_model": "nomic-ai/CodeRankEmbed", + "embedding_dimension": 768, + }, + ) + + registry._load_vector_store(entry) + + assert created[0].kwargs["embedding_provider"] == "huggingface" + assert created[0].kwargs["embedding_model"] == "nomic-ai/CodeRankEmbed" + assert created[0].kwargs["dimension"] == 768 + + def test_vector_store_cache_separates_model_revisions(monkeypatch): created = [] @@ -434,7 +474,7 @@ def entry(revision): assert second.kwargs["embedding"] is None -def test_remote_embedding_override_drops_huggingface_constructor_options( +def test_remote_embedding_override_cannot_replace_artifact_route( monkeypatch, ): created = [] @@ -472,14 +512,10 @@ def load(self, _path): }, ) - registry._load_vector_store(entry) + with pytest.raises(ValueError, match="endpoint does not match"): + registry._load_vector_store(entry) - kwargs = created[0].kwargs - assert kwargs["embedding_provider"] == "openai" - assert kwargs["base_url"] == "http://embed.local/v1" - assert kwargs["api_key"] == "secret" - assert "model_kwargs" not in kwargs - assert "revision" not in kwargs + assert created == [] def test_ask_model_receives_its_own_endpoint(monkeypatch):