From 4f9df7668340fe35abd4f27facf4e1f1df533e74 Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Tue, 4 Aug 2026 22:02:49 -0700 Subject: [PATCH 1/3] feat(providers): add secret-safe embedding routes Persist only canonical provider, model, endpoint, dimension, and vector-semantic options in embedding artifact identity. Rebind credentials at runtime and reject incompatible provider substitutions across compiler, MCP, skills, and Wiki loaders. Verified with the full unit tier (2568 passed), focused provider/runtime tests (183 passed), and pre-commit. --- codenib/compiler/index_builders.py | 155 ++++- codenib/compiler/skill_context.py | 30 +- codenib/index/embedding/vector_store.py | 53 +- codenib/mcp/context.py | 37 +- codenib/provider_routes.py | 643 ++++++++++++++++++++ codenib/web/repo_registry.py | 69 +-- test/compiler/test_index_compiler.py | 106 +++- test/compiler/test_skill_context.py | 2 +- test/index/test_vector_artifact_identity.py | 87 +++ test/mcp_server/test_context.py | 122 ++++ test/test_provider_routes.py | 318 ++++++++++ test/web/test_repo_registry.py | 22 +- 12 files changed, 1514 insertions(+), 130 deletions(-) create mode 100644 codenib/provider_routes.py create mode 100644 test/index/test_vector_artifact_identity.py create mode 100644 test/test_provider_routes.py 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..7627ecc5 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,8 @@ 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", "github_models", + 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). @@ -324,7 +335,11 @@ def _get_index_and_docs(self, level: Level) -> tuple[faiss.Index, List[_Document def _initialize_embedding_model(self, **kwargs): """Initialize the embedding model based on provider.""" - if self.embedding_provider.lower() == "openai": + if self.embedding_provider.lower() in { + "openai", + "github_models", + "github-models", + }: return _OpenAIEmbeddingWrapper(model=self.embedding_model, **kwargs) elif self.embedding_provider.lower() == "huggingface": return _HuggingFaceEmbeddingWrapper( @@ -1030,10 +1045,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 +1080,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..c61f10bf --- /dev/null +++ b/codenib/provider_routes.py @@ -0,0 +1,643 @@ +# 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"] + +GITHUB_MODELS_PROVIDER = "github_models" +GITHUB_MODELS_BASE_URL = "https://models.github.ai/inference" +INFERENCE_ROUTE_SCHEMA = "codenib.inference-route.v1" + +_PROVIDER_ALIASES = { + "github-models": GITHUB_MODELS_PROVIDER, + "github_models": GITHUB_MODELS_PROVIDER, + "hugging-face": "huggingface", + "hugging_face": "huggingface", +} +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") +_GITHUB_MODEL_RE = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-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() + 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(provider: str, 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") + if provider == GITHUB_MODELS_PROVIDER and not _GITHUB_MODEL_RE.fullmatch( + normalized + ): + raise ValueError( + "GitHub Models ids must use the documented publisher/model form" + ) + 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(canonical_provider, 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 == GITHUB_MODELS_PROVIDER: + if normalized_endpoint and normalized_endpoint != GITHUB_MODELS_BASE_URL: + raise ValueError( + "GitHub Models uses its fixed inference endpoint; select openai " + "for a custom compatible endpoint" + ) + normalized_endpoint = GITHUB_MODELS_BASE_URL + if selected_env is None: + selected_env = ( + "GITHUB_TOKEN" + if environment.get("GITHUB_TOKEN") or not environment.get("GH_TOKEN") + else "GH_TOKEN" + ) + client_model = ( + f"openai/{canonical_model}" if operation == "chat" else canonical_model + ) + credential_required = True + elif 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, openai, or github_models" + ) + 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 == GITHUB_MODELS_PROVIDER and options: + raise ValueError( + "GitHub Models embedding routes do not accept vector-shaping 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__ = [ + "GITHUB_MODELS_BASE_URL", + "GITHUB_MODELS_PROVIDER", + "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..375bd81a 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 @@ -396,59 +396,40 @@ 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, - ) + 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/test/compiler/test_index_compiler.py b/test/compiler/test_index_compiler.py index 792a4b5e..d383e19c 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,80 @@ 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="openai/text-embedding-3-small", + embedding_provider="github_models", + embedding_dimension=1536, + embedding_runtime_kwargs={"api_key": "first-secret", "timeout": 10}, + ) + second = VectorIndexBuilder( + embedding_model="openai/text-embedding-3-small", + embedding_provider="github_models", + 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="openai/text-embedding-3-small", + embedding_provider="github_models", + 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://models.github.ai/inference" + ) + call = mock_build_fn.call_args.kwargs + assert call["embedding_kwargs"]["api_key"] == "runtime-secret" + assert call["embedding_kwargs"]["base_url"] == ( + "https://models.github.ai/inference" + ) + # --------------------------------------------------------------------------- # SymbolGraphBuilder @@ -600,6 +686,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 +710,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 +724,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..15f0612f 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,117 @@ 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_github_models_credential_without_persisting_it( + tmp_path: Path, + monkeypatch, +) -> None: + vector_dir = tmp_path / "vector" + vector_dir.mkdir() + route = resolve_inference_route( + operation="embeddings", + provider="github_models", + model="openai/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("GITHUB_TOKEN", "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"] == "github_models" + assert kwargs["base_url"] == "https://models.github.ai/inference" + 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="github_models", + model="openai/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("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", 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..0b1c85a0 --- /dev/null +++ b/test/test_provider_routes.py @@ -0,0 +1,318 @@ +# 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 ( + GITHUB_MODELS_BASE_URL, + embedding_compatibility_options, + normalize_endpoint, + resolve_embedding_artifact_route, + resolve_inference_route, + validate_embedding_runtime_options, +) + + +def test_github_models_route_discovers_action_token_without_storing_it() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="github-models", + model="openai/text-embedding-3-small", + dimension=1536, + environ={"GITHUB_TOKEN": "action-secret"}, + ) + + assert route.provider == "github_models" + assert route.endpoint == GITHUB_MODELS_BASE_URL + assert route.credential_env == "GITHUB_TOKEN" + assert route.client_kwargs({"GITHUB_TOKEN": "action-secret"}) == { + "base_url": GITHUB_MODELS_BASE_URL, + "api_key": "action-secret", + } + serialized = json.dumps(route.public_identity(), sort_keys=True) + assert "action-secret" not in serialized + assert "GITHUB_TOKEN" not in serialized + + +def test_github_models_route_uses_explicit_credential_then_gh_token() -> None: + explicit = resolve_inference_route( + operation="chat", + provider="github_models", + model="openai/gpt-4.1", + credential_env="MODELS_TOKEN", + environ={"GITHUB_TOKEN": "action", "MODELS_TOKEN": "explicit"}, + ) + fallback = resolve_inference_route( + operation="chat", + provider="github_models", + model="openai/gpt-4.1", + environ={"GH_TOKEN": "cli-token"}, + ) + + assert explicit.client_model == "openai/openai/gpt-4.1" + assert explicit.credential_env == "MODELS_TOKEN" + assert explicit.credential({"MODELS_TOKEN": "explicit"}) == "explicit" + assert fallback.credential_env == "GH_TOKEN" + + +def test_github_models_route_reports_missing_credentials_without_token_data() -> None: + route = resolve_inference_route( + operation="embeddings", + provider="github_models", + model="openai/text-embedding-3-small", + dimension=1536, + environ={}, + ) + + with pytest.raises(ValueError, match="GITHUB_TOKEN") as error: + route.client_kwargs({}) + assert "Bearer" not in str(error.value) + + +@pytest.mark.parametrize( + "value", + [ + "models.github.ai/inference", + "https://token@models.github.ai/inference", + "https://models.github.ai/inference?api_key=secret", + "https://models.github.ai/inference#fragment", + "https://models.github.ai/inference/../other", + "https://models.github.ai/inference/embeddings", + "https://models.github.ai/inference/%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://MODELS.GITHUB.AI/inference/") + == GITHUB_MODELS_BASE_URL + ) + + +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="github_models", + ) == {"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="github_models", + model="openai/text-embedding-3-small", + 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..dcf68827 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) @@ -434,7 +442,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 +480,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): From 6ab5db1882517caf52f9bf894940c8993afdcbdd Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Wed, 5 Aug 2026 00:12:34 -0700 Subject: [PATCH 2/3] fix(providers): remove retired GitHub Models route --- codenib/index/embedding/vector_store.py | 9 +-- codenib/provider_routes.py | 50 +++------------ test/compiler/test_index_compiler.py | 19 +++--- test/mcp_server/test_context.py | 19 +++--- test/test_provider_routes.py | 84 ++++++++++++++----------- 5 files changed, 80 insertions(+), 101 deletions(-) diff --git a/codenib/index/embedding/vector_store.py b/codenib/index/embedding/vector_store.py index 7627ecc5..3218760b 100644 --- a/codenib/index/embedding/vector_store.py +++ b/codenib/index/embedding/vector_store.py @@ -260,8 +260,7 @@ def __init__( Args: embedding_model: Name of the embedding model to use - embedding_provider: Provider for embeddings ("openai", "github_models", - or "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). @@ -335,11 +334,7 @@ def _get_index_and_docs(self, level: Level) -> tuple[faiss.Index, List[_Document def _initialize_embedding_model(self, **kwargs): """Initialize the embedding model based on provider.""" - if self.embedding_provider.lower() in { - "openai", - "github_models", - "github-models", - }: + if self.embedding_provider.lower() == "openai": return _OpenAIEmbeddingWrapper(model=self.embedding_model, **kwargs) elif self.embedding_provider.lower() == "huggingface": return _HuggingFaceEmbeddingWrapper( diff --git a/codenib/provider_routes.py b/codenib/provider_routes.py index c61f10bf..4404e0b9 100644 --- a/codenib/provider_routes.py +++ b/codenib/provider_routes.py @@ -16,20 +16,14 @@ InferenceOperation = Literal["chat", "embeddings"] -GITHUB_MODELS_PROVIDER = "github_models" -GITHUB_MODELS_BASE_URL = "https://models.github.ai/inference" INFERENCE_ROUTE_SCHEMA = "codenib.inference-route.v1" _PROVIDER_ALIASES = { - "github-models": GITHUB_MODELS_PROVIDER, - "github_models": GITHUB_MODELS_PROVIDER, "hugging-face": "huggingface", "hugging_face": "huggingface", } +_RETIRED_PROVIDERS = {"github-models", "github_models"} _PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") -_GITHUB_MODEL_RE = re.compile( - r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.:-]*$" -) _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _OPERATION_PATH_SUFFIXES = ( "/chat/completions", @@ -90,6 +84,11 @@ 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}") @@ -300,18 +299,12 @@ def validate_embedding_runtime_options( return validated -def _normalize_model(provider: str, model: str) -> str: +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") - if provider == GITHUB_MODELS_PROVIDER and not _GITHUB_MODEL_RE.fullmatch( - normalized - ): - raise ValueError( - "GitHub Models ids must use the documented publisher/model form" - ) return normalized @@ -424,30 +417,13 @@ def resolve_inference_route( if operation not in {"chat", "embeddings"}: raise ValueError(f"unsupported inference operation: {operation!r}") canonical_provider = normalize_provider(provider) - canonical_model = _normalize_model(canonical_provider, model) + 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 == GITHUB_MODELS_PROVIDER: - if normalized_endpoint and normalized_endpoint != GITHUB_MODELS_BASE_URL: - raise ValueError( - "GitHub Models uses its fixed inference endpoint; select openai " - "for a custom compatible endpoint" - ) - normalized_endpoint = GITHUB_MODELS_BASE_URL - if selected_env is None: - selected_env = ( - "GITHUB_TOKEN" - if environment.get("GITHUB_TOKEN") or not environment.get("GH_TOKEN") - else "GH_TOKEN" - ) - client_model = ( - f"openai/{canonical_model}" if operation == "chat" else canonical_model - ) - credential_required = True - elif canonical_provider == "huggingface": + if canonical_provider == "huggingface": if operation != "embeddings": raise ValueError("huggingface is supported only for embeddings") if normalized_endpoint or selected_env: @@ -471,7 +447,7 @@ def resolve_inference_route( if operation == "embeddings": raise ValueError( f"unsupported embedding provider: {canonical_provider}; use " - "huggingface, openai, or github_models" + "huggingface or openai" ) client_model = ( canonical_model @@ -488,10 +464,6 @@ def resolve_inference_route( ): raise ValueError("embedding dimension must be a positive integer") options = embedding_compatibility_options(compatibility_options) - if canonical_provider == GITHUB_MODELS_PROVIDER and options: - raise ValueError( - "GitHub Models embedding routes do not accept vector-shaping options" - ) if canonical_provider == "openai": unsupported = sorted(set(options) - {"dimensions"}) if unsupported: @@ -630,8 +602,6 @@ def resolve_embedding_artifact_route( __all__ = [ - "GITHUB_MODELS_BASE_URL", - "GITHUB_MODELS_PROVIDER", "INFERENCE_ROUTE_SCHEMA", "InferenceRoute", "embedding_compatibility_options", diff --git a/test/compiler/test_index_compiler.py b/test/compiler/test_index_compiler.py index d383e19c..275b5a92 100644 --- a/test/compiler/test_index_compiler.py +++ b/test/compiler/test_index_compiler.py @@ -224,14 +224,16 @@ def test_artifact_identity_is_shared_by_full_and_incremental_statuses(self): def test_runtime_options_do_not_change_identity_or_appear_in_repr(self): first = VectorIndexBuilder( - embedding_model="openai/text-embedding-3-small", - embedding_provider="github_models", + 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="openai/text-embedding-3-small", - embedding_provider="github_models", + 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}, ) @@ -271,8 +273,9 @@ 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="openai/text-embedding-3-small", - embedding_provider="github_models", + 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}, @@ -288,12 +291,12 @@ def test_remote_runtime_secret_is_not_persisted(self, mock_build_fn, tmp_path): assert "runtime-secret" not in serialized assert "MODELS_TOKEN" not in serialized assert status.metadata["embedding_endpoint"] == ( - "https://models.github.ai/inference" + "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://models.github.ai/inference" + "https://inference.example.test/v1" ) diff --git a/test/mcp_server/test_context.py b/test/mcp_server/test_context.py index 15f0612f..faa791bf 100644 --- a/test/mcp_server/test_context.py +++ b/test/mcp_server/test_context.py @@ -296,7 +296,7 @@ def test_validate_views_probes_vector_without_loading_embedding_model( assert cls.call_args.kwargs["embedding"].dimension == 384 -def test_load_vector_rebinds_github_models_credential_without_persisting_it( +def test_load_vector_rebinds_openai_credential_without_persisting_it( tmp_path: Path, monkeypatch, ) -> None: @@ -304,8 +304,8 @@ def test_load_vector_rebinds_github_models_credential_without_persisting_it( vector_dir.mkdir() route = resolve_inference_route( operation="embeddings", - provider="github_models", - model="openai/text-embedding-3-small", + provider="openai", + model="text-embedding-3-small", dimension=1536, environ={}, ) @@ -334,7 +334,7 @@ def test_load_vector_rebinds_github_models_credential_without_persisting_it( }, ) manifest.save(tmp_path / "repo_manifest.json") - monkeypatch.setenv("GITHUB_TOKEN", "runtime-secret") + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") vector = MagicMock() vector.embedding_model = route.model @@ -346,8 +346,8 @@ def test_load_vector_rebinds_github_models_credential_without_persisting_it( ctx = ServerContext.load(tmp_path / "repo_manifest.json") kwargs = cls.call_args.kwargs - assert kwargs["embedding_provider"] == "github_models" - assert kwargs["base_url"] == "https://models.github.ai/inference" + 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 @@ -361,8 +361,8 @@ def test_validate_views_does_not_require_remote_embedding_credentials( vector_dir.mkdir() route = resolve_inference_route( operation="embeddings", - provider="github_models", - model="openai/text-embedding-3-small", + provider="openai", + model="text-embedding-3-small", dimension=1536, environ={}, ) @@ -390,8 +390,7 @@ def test_validate_views_does_not_require_remote_embedding_credentials( ), }, ) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) vector = MagicMock() vector.embedding_model = route.model vector.get_stats.return_value = {"total_documents": 3} diff --git a/test/test_provider_routes.py b/test/test_provider_routes.py index 0b1c85a0..24c6d94d 100644 --- a/test/test_provider_routes.py +++ b/test/test_provider_routes.py @@ -9,7 +9,6 @@ import pytest from codenib.provider_routes import ( - GITHUB_MODELS_BASE_URL, embedding_compatibility_options, normalize_endpoint, resolve_embedding_artifact_route, @@ -18,72 +17,84 @@ ) -def test_github_models_route_discovers_action_token_without_storing_it() -> None: +def test_openai_route_uses_explicit_token_without_storing_it() -> None: route = resolve_inference_route( operation="embeddings", - provider="github-models", - model="openai/text-embedding-3-small", + provider="openai", + model="text-embedding-3-small", + endpoint="https://inference.example.test/v1", dimension=1536, - environ={"GITHUB_TOKEN": "action-secret"}, + credential_env="MODELS_TOKEN", + environ={"MODELS_TOKEN": "action-secret"}, ) - assert route.provider == "github_models" - assert route.endpoint == GITHUB_MODELS_BASE_URL - assert route.credential_env == "GITHUB_TOKEN" - assert route.client_kwargs({"GITHUB_TOKEN": "action-secret"}) == { - "base_url": GITHUB_MODELS_BASE_URL, + 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 "GITHUB_TOKEN" not in serialized + assert "MODELS_TOKEN" not in serialized -def test_github_models_route_uses_explicit_credential_then_gh_token() -> None: +def test_openai_route_uses_explicit_credential_then_default() -> None: explicit = resolve_inference_route( operation="chat", - provider="github_models", - model="openai/gpt-4.1", + provider="openai", + model="gpt-4.1", credential_env="MODELS_TOKEN", - environ={"GITHUB_TOKEN": "action", "MODELS_TOKEN": "explicit"}, + environ={"OPENAI_API_KEY": "default", "MODELS_TOKEN": "explicit"}, ) fallback = resolve_inference_route( operation="chat", - provider="github_models", - model="openai/gpt-4.1", - environ={"GH_TOKEN": "cli-token"}, + provider="openai", + model="gpt-4.1", + environ={"OPENAI_API_KEY": "default"}, ) - assert explicit.client_model == "openai/openai/gpt-4.1" + 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 == "GH_TOKEN" + assert fallback.credential_env == "OPENAI_API_KEY" -def test_github_models_route_reports_missing_credentials_without_token_data() -> None: +def test_openai_route_reports_missing_credentials_without_token_data() -> None: route = resolve_inference_route( operation="embeddings", - provider="github_models", - model="openai/text-embedding-3-small", + provider="openai", + model="text-embedding-3-small", dimension=1536, environ={}, ) - with pytest.raises(ValueError, match="GITHUB_TOKEN") as error: + 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", [ - "models.github.ai/inference", - "https://token@models.github.ai/inference", - "https://models.github.ai/inference?api_key=secret", - "https://models.github.ai/inference#fragment", - "https://models.github.ai/inference/../other", - "https://models.github.ai/inference/embeddings", - "https://models.github.ai/inference/%65mbeddings", + "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: @@ -93,8 +104,8 @@ def test_endpoint_normalization_rejects_unsafe_or_operation_urls(value: str) -> def test_endpoint_normalization_is_stable() -> None: assert ( - normalize_endpoint("HTTPS://MODELS.GITHUB.AI/inference/") - == GITHUB_MODELS_BASE_URL + normalize_endpoint("HTTPS://INFERENCE.EXAMPLE.TEST/v1/") + == "https://inference.example.test/v1" ) @@ -243,7 +254,7 @@ def test_huggingface_route_rejects_remote_configuration() -> None: def test_runtime_options_are_provider_specific_and_cannot_override_semantics() -> None: assert validate_embedding_runtime_options( {"api-key": "secret", "max-retries": 2}, - provider="github_models", + provider="openai", ) == {"api_key": "secret", "max_retries": 2} assert validate_embedding_runtime_options( {"encode_kwargs": {"batch-size": 8}}, @@ -265,8 +276,9 @@ def test_runtime_options_are_provider_specific_and_cannot_override_semantics() - def test_schema_v3_artifact_route_round_trips_and_rejects_drift() -> None: route = resolve_inference_route( operation="embeddings", - provider="github_models", - model="openai/text-embedding-3-small", + provider="openai", + model="text-embedding-3-small", + endpoint="https://inference.example.test/v1", dimension=1536, environ={}, ) From 80743bfae8581ae174b98cc33ac05f14c17db81c Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Wed, 5 Aug 2026 01:17:16 -0700 Subject: [PATCH 3/3] fix(providers): preserve legacy prebuilt routes Infer the provider only for legacy prebuilt manifests that predate route identity, and persist provider and endpoint metadata in newly generated QA manifests. --- codenib/web/repo_registry.py | 18 +++++++++++++++++- scripts/build_qa_index.py | 13 +++++++++---- test/web/test_repo_registry.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/codenib/web/repo_registry.py b/codenib/web/repo_registry.py index 375bd81a..fe3e96e0 100644 --- a/codenib/web/repo_registry.py +++ b/codenib/web/repo_registry.py @@ -395,7 +395,23 @@ 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 {} + 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: 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/web/test_repo_registry.py b/test/web/test_repo_registry.py index dcf68827..ce8dc124 100644 --- a/test/web/test_repo_registry.py +++ b/test/web/test_repo_registry.py @@ -406,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 = []