diff --git a/codenib/cli.py b/codenib/cli.py index aa480f8b..90443515 100644 --- a/codenib/cli.py +++ b/codenib/cli.py @@ -14,10 +14,17 @@ import shutil import sys from collections import Counter +from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Sequence from ._version import package_version +from .provider_routes import ( + InferenceRoute, + normalize_provider, + resolve_embedding_artifact_route, + resolve_inference_route, +) from .repository_filters import DEFAULT_IGNORED_DIRS _PRESET_VIEWS = { @@ -26,12 +33,178 @@ "graph": ("bm25", "symbol_graph"), "full": ("bm25", "vector", "symbol_graph", "zoekt"), } +_REMOTE_EMBEDDING_DEFAULTS = { + "openai": ("text-embedding-3-small", 1536), +} class CLIError(RuntimeError): """A user-actionable command error.""" +@dataclass(frozen=True, slots=True) +class _ResolvedModelBackend: + model: str + api_base: str | None + api_key: str | None = field(default=None, repr=False) + auth_source: str | None = None + + +def _optional_int(value: object, *, source: str) -> int | None: + if value in (None, ""): + return None + if isinstance(value, bool): + raise CLIError(f"{source} must be a positive integer") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise CLIError(f"{source} must be a positive integer") from exc + if parsed <= 0: + raise CLIError(f"{source} must be a positive integer") + return parsed + + +def _embedding_route_for_args(args: argparse.Namespace) -> InferenceRoute: + """Resolve the secret-free embedding identity selected by CLI and env.""" + + from .index.embedding.model_policy import ( + DEFAULT_EMBEDDING_DIMENSION, + DEFAULT_EMBEDDING_MODEL, + ) + + raw_provider = ( + getattr(args, "embedding_provider", None) + or os.environ.get("CODENIB_EMBEDDING_PROVIDER") + or "huggingface" + ) + try: + provider = normalize_provider(raw_provider) + except ValueError as exc: + raise CLIError(str(exc)) from exc + + explicit_model = getattr(args, "embedding_model", None) or os.environ.get( + "CODENIB_EMBEDDING_MODEL" + ) + dimension = _optional_int( + getattr(args, "embedding_dimension", None) + or os.environ.get("CODENIB_EMBEDDING_DIMENSION"), + source="embedding dimension", + ) + if provider in _REMOTE_EMBEDDING_DEFAULTS: + default_model, default_dimension = _REMOTE_EMBEDDING_DEFAULTS[provider] + model = explicit_model or default_model + if dimension is None: + if explicit_model and explicit_model != default_model: + raise CLIError( + "--embedding-dimension is required for a non-default remote model" + ) + dimension = default_dimension + else: + model = explicit_model or DEFAULT_EMBEDDING_MODEL + dimension = dimension or DEFAULT_EMBEDDING_DIMENSION + + endpoint = ( + getattr(args, "embedding_endpoint", None) + or os.environ.get("CODENIB_EMBEDDING_ENDPOINT") + or os.environ.get("CODENIB_EMBEDDING_BASE_URL") + ) + credential_env = getattr(args, "embedding_api_key_env", None) or os.environ.get( + "CODENIB_EMBEDDING_API_KEY_ENV" + ) + try: + return resolve_inference_route( + operation="embeddings", + provider=provider, + model=model, + endpoint=endpoint, + dimension=dimension, + credential_env=credential_env, + ) + except ValueError as exc: + raise CLIError(str(exc)) from exc + + +def _embedding_identity_is_configured(args: argparse.Namespace) -> bool: + return any( + ( + getattr(args, "embedding_provider", None), + getattr(args, "embedding_model", None), + getattr(args, "embedding_dimension", None), + getattr(args, "embedding_endpoint", None), + os.environ.get("CODENIB_EMBEDDING_PROVIDER"), + os.environ.get("CODENIB_EMBEDDING_MODEL"), + os.environ.get("CODENIB_EMBEDDING_DIMENSION"), + os.environ.get("CODENIB_EMBEDDING_ENDPOINT"), + os.environ.get("CODENIB_EMBEDDING_BASE_URL"), + ) + ) + + +def _model_backend_for_args( + args: argparse.Namespace, + *, + options: dict[str, object] | None = None, +) -> _ResolvedModelBackend | None: + """Resolve a chat route while retaining legacy raw LiteLLM model strings.""" + + model = ( + getattr(args, "model", None) + or os.environ.get("CODENIB_DEMO_WIKI_MODEL") + or os.environ.get("CODENIB_DEMO_MODEL") + ) + if not model: + return None + provider = getattr(args, "model_provider", None) or os.environ.get( + "CODENIB_DEMO_MODEL_PROVIDER" + ) + api_base = ( + getattr(args, "api_base", None) + or os.environ.get("CODENIB_DEMO_WIKI_API_BASE") + or os.environ.get("CODENIB_DEMO_API_BASE") + ) + key_env = getattr(args, "api_key_env", None) + direct_key_source = None + direct_key = None + if key_env: + direct_key_source = key_env + direct_key = os.environ.get(key_env) + elif os.environ.get("CODENIB_DEMO_WIKI_API_KEY"): + direct_key_source = "CODENIB_DEMO_WIKI_API_KEY" + direct_key = os.environ[direct_key_source] + elif os.environ.get("CODENIB_DEMO_API_KEY"): + direct_key_source = "CODENIB_DEMO_API_KEY" + direct_key = os.environ[direct_key_source] + + if key_env and not direct_key: + raise CLIError(f"{key_env} is unset or empty") + if not provider: + return _ResolvedModelBackend( + model=model, + api_base=api_base, + api_key=direct_key, + auth_source=direct_key_source, + ) + + try: + route = resolve_inference_route( + operation="chat", + provider=provider, + model=model, + endpoint=api_base, + credential_env=key_env, + compatibility_options=options, + ) + api_key = direct_key if direct_key is not None else route.credential() + except ValueError as exc: + raise CLIError(str(exc)) from exc + return _ResolvedModelBackend( + model=route.client_model, + api_base=route.endpoint, + api_key=api_key, + auth_source=direct_key_source or route.credential_env, + ) + + def _split_values(values: Iterable[str] | None) -> list[str]: result: list[str] = [] for value in values or (): @@ -141,17 +314,35 @@ def index_repository( languages: Sequence[str], views: Sequence[str], rebuild: bool = False, + embedding_provider: str = "huggingface", + embedding_model: str | None = None, + embedding_dimension: int | None = None, + embedding_endpoint: str | None = None, + embedding_credential_env: str | None = None, ): """Build or update the requested repository views.""" from .compiler.index_builders import IndexBuilderRegistry, register_default_builders from .compiler.index_compiler import IndexCompiler, IndexCompilerConfig from .compiler.manifest import MANIFEST_FILENAME + from .index.embedding.model_policy import ( + DEFAULT_EMBEDDING_DIMENSION, + DEFAULT_EMBEDDING_MODEL, + ) from .paths import repo_index_dir registry = IndexBuilderRegistry() register_default_builders( registry, languages=list(languages), + embedding_provider=embedding_provider, + embedding_model=embedding_model or DEFAULT_EMBEDDING_MODEL, + embedding_dimension=( + DEFAULT_EMBEDDING_DIMENSION + if embedding_dimension is None + else embedding_dimension + ), + embedding_endpoint=embedding_endpoint, + embedding_credential_env=embedding_credential_env, allow_partial_graph_languages=True, ) compiler = IndexCompiler( @@ -212,13 +403,30 @@ def _run_index(args: argparse.Namespace) -> int: repo_path = resolve_repo_path(args.repo) languages = _selected_languages(repo_path, args.language) views = _selected_views(args.preset, args.view) - _check_view_dependencies(views) - manifest, failed = index_repository( - repo_path, - languages=languages, - views=views, - rebuild=args.rebuild, - ) + embedding_route = None + if "vector" in views: + embedding_route = _embedding_route_for_args(args) + _check_view_dependencies(views, embedding_provider=embedding_route.provider) + try: + embedding_route.credential() + except ValueError as exc: + raise CLIError(str(exc)) from exc + else: + _check_view_dependencies(views) + index_kwargs = { + "languages": languages, + "views": views, + "rebuild": args.rebuild, + } + if embedding_route is not None: + index_kwargs.update( + embedding_provider=embedding_route.provider, + embedding_model=embedding_route.model, + embedding_dimension=embedding_route.dimension, + embedding_endpoint=embedding_route.endpoint, + embedding_credential_env=embedding_route.credential_env, + ) + manifest, failed = index_repository(repo_path, **index_kwargs) _print_index_summary(manifest, views) if failed: print( @@ -315,6 +523,8 @@ def _audit_local_wiki(local) -> dict[str, object]: config.model_api_base = local.runtime_env["CODENIB_DEMO_API_BASE"] if local.runtime_env.get("CODENIB_DEMO_API_KEY"): config.model_api_key = local.runtime_env["CODENIB_DEMO_API_KEY"] + if local.runtime_env.get("CODENIB_EMBEDDING_API_KEY"): + config.embedding_api_key = local.runtime_env["CODENIB_EMBEDDING_API_KEY"] registry = RepoRegistry(config) registry.load_all() @@ -372,16 +582,37 @@ def _print_wiki_audit(report: dict[str, object], *, as_json: bool = False) -> No print("Result: PASS" if report.get("passed") else "Result: FAIL") +def _manifest_embedding_route( + manifest_path: Path, + *, + credential_env: str | None, +) -> InferenceRoute | None: + from .compiler.manifest import RepoManifest + + manifest = RepoManifest.load(manifest_path) + entry = manifest.indexes.get("vector") + if entry is None or not manifest.index_is_current("vector"): + return None + try: + return resolve_embedding_artifact_route( + entry.config, + credential_env=credential_env, + ) + except ValueError as exc: + raise CLIError(str(exc)) from exc + + def _run_wiki(args: argparse.Namespace) -> int: repo_path = resolve_repo_path(args.repo) languages = _selected_languages(repo_path, args.language) views = _selected_views(args.preset, args.view) - _check_view_dependencies(views) audit = bool(args.audit or args.audit_json) + model_options = _model_options_for_args(args) if ( args.agent_wiki or audit or args.model + or args.model_provider or args.api_base or args.api_key_env or args.model_option @@ -391,20 +622,41 @@ def _run_wiki(args: argparse.Namespace) -> int: extra="agent", feature="model-backed Wiki features", ) - if args.api_key_env and not os.environ.get(args.api_key_env): - raise CLIError( - "API key environment variable is unset or empty: " f"{args.api_key_env}" - ) + model_backend = _model_backend_for_args(args, options=model_options) + if args.model_provider and model_backend is None: + raise CLIError("--model is required when --model-provider is selected") if args.no_index: manifest_path = resolve_manifest_path(str(repo_path)) else: - manifest, failed = index_repository( - repo_path, - languages=languages, - views=views, - rebuild=args.rebuild, - ) + build_embedding_route = None + if "vector" in views: + build_embedding_route = _embedding_route_for_args(args) + _check_view_dependencies( + views, + embedding_provider=build_embedding_route.provider, + ) + try: + build_embedding_route.credential() + except ValueError as exc: + raise CLIError(str(exc)) from exc + else: + _check_view_dependencies(views) + + index_kwargs = { + "languages": languages, + "views": views, + "rebuild": args.rebuild, + } + if build_embedding_route is not None: + index_kwargs.update( + embedding_provider=build_embedding_route.provider, + embedding_model=build_embedding_route.model, + embedding_dimension=build_embedding_route.dimension, + embedding_endpoint=build_embedding_route.endpoint, + embedding_credential_env=build_embedding_route.credential_env, + ) + manifest, failed = index_repository(repo_path, **index_kwargs) _print_index_summary(manifest, views) if failed: raise CLIError( @@ -413,6 +665,45 @@ def _run_wiki(args: argparse.Namespace) -> int: ) manifest_path = resolve_manifest_path(str(repo_path)) + credential_env = args.embedding_api_key_env or os.environ.get( + "CODENIB_EMBEDDING_API_KEY_ENV" + ) + embedding_route = _manifest_embedding_route( + manifest_path, + credential_env=credential_env, + ) + if embedding_route is not None: + if _embedding_identity_is_configured(args): + requested_route = _embedding_route_for_args(args) + if ( + requested_route.compatibility_fingerprint + != embedding_route.compatibility_fingerprint + ): + raise CLIError( + "selected embedding route does not match the current vector " + "artifact; rebuild the vector view or remove the route override" + ) + _check_view_dependencies( + ["vector"], + embedding_provider=embedding_route.provider, + ) + try: + embedding_route.credential() + except ValueError as exc: + raise CLIError(str(exc)) from exc + elif "vector" in views and not args.no_index: + raise CLIError("the requested vector view is missing from the manifest") + + if args.no_index: + dependency_views = [view for view in views if view != "vector"] + if dependency_views: + _check_view_dependencies(dependency_views) + if "vector" in views and embedding_route is None: + raise CLIError( + "--no-index requested a vector view, but the manifest has no " + "current vector artifact" + ) + from .web.launcher import launch_local_wiki from .web.local import prepare_local_wiki @@ -422,10 +713,13 @@ def _run_wiki(args: argparse.Namespace) -> int: manifest_path, frontend_port=args.port, agent_wiki=args.agent_wiki or audit, - model=args.model, - api_base=args.api_base, - api_key_env=args.api_key_env, - model_options=_model_options_for_args(args), + model=model_backend.model if model_backend else None, + api_base=model_backend.api_base if model_backend else None, + api_key_env=model_backend.auth_source if model_backend else None, + model_options=model_options, + embedding_api_key_env=( + embedding_route.credential_env if embedding_route else None + ), ) except ValueError as exc: raise CLIError(str(exc)) from exc @@ -471,11 +765,20 @@ def _require_modules( ) -def _check_view_dependencies(views: Sequence[str]) -> None: +def _check_view_dependencies( + views: Sequence[str], + *, + embedding_provider: str = "huggingface", +) -> None: if "vector" in views: + provider = normalize_provider(embedding_provider) + embedding_module = ( + "sentence_transformers" if provider == "huggingface" else "openai" + ) + extra = "semantic" if provider == "huggingface" else "semantic-remote" _require_modules( - ("faiss", "sentence_transformers"), - extra="semantic", + ("faiss", embedding_module), + extra=extra, feature="the vector view", ) if "symbol_graph" in views: @@ -489,33 +792,6 @@ def _check_view_dependencies(views: Sequence[str]) -> None: def _doctor_model_config( args: argparse.Namespace, ) -> tuple[str, bool, str] | None: - model = ( - getattr(args, "model", None) - or os.environ.get("CODENIB_DEMO_WIKI_MODEL") - or os.environ.get("CODENIB_DEMO_MODEL") - ) - if not model: - return None - api_base = ( - getattr(args, "api_base", None) - or os.environ.get("CODENIB_DEMO_WIKI_API_BASE") - or os.environ.get("CODENIB_DEMO_API_BASE") - ) - key_env = getattr(args, "api_key_env", None) - api_key = ( - os.environ.get(key_env) - if key_env - else os.environ.get("CODENIB_DEMO_WIKI_API_KEY") - or os.environ.get("CODENIB_DEMO_API_KEY") - ) - if key_env and not api_key: - return ( - "Model configuration", - False, - f"{model}; {key_env} is unset or empty", - ) - if not _check_module("litellm"): - return ("Model configuration", False, f"{model}; LiteLLM is missing") options = _model_options_for_args( args, include_wiki_environment=( @@ -523,29 +799,31 @@ def _doctor_model_config( and bool(os.environ.get("CODENIB_DEMO_WIKI_MODEL")) ), ) + try: + backend = _model_backend_for_args(args, options=options) + except CLIError as exc: + model = getattr(args, "model", None) or "model" + return ("Model configuration", False, f"{model}; {exc}") + if backend is None: + return None + if not _check_module("litellm"): + return ( + "Model configuration", + False, + f"{backend.model}; LiteLLM is missing", + ) try: from .llm.diagnostics import diagnose_model_backend report = diagnose_model_backend( - model=model, - api_base=api_base, - api_key=api_key, - auth_source=( - key_env - or ( - "CODENIB_DEMO_WIKI_API_KEY" - if os.environ.get("CODENIB_DEMO_WIKI_API_KEY") - else ( - "CODENIB_DEMO_API_KEY" - if os.environ.get("CODENIB_DEMO_API_KEY") - else None - ) - ) - ), + model=backend.model, + api_base=backend.api_base, + api_key=backend.api_key, + auth_source=backend.auth_source, options=options, ) except Exception as exc: # noqa: BLE001 - diagnostic must report, not crash - return ("Model configuration", False, f"{model}; {exc}") + return ("Model configuration", False, f"{backend.model}; {exc}") return ("Model configuration", report.configured, report.detail()) @@ -565,6 +843,57 @@ def _doctor_rows( runtime_detail = ( "not required (prebuilt frontend)" if frontend_prebuilt else node_detail ) + try: + embedding_route = _embedding_route_for_args(args or argparse.Namespace()) + try: + embedding_route.credential() + route_ok = True + route_issue = "" + except ValueError as exc: + route_ok = False + route_issue = str(exc) + embedding_module = ( + "sentence_transformers" + if embedding_route.provider == "huggingface" + else "openai" + ) + embedding_label = ( + "sentence-transformers" + if embedding_route.provider == "huggingface" + else "OpenAI SDK" + ) + route_parts = [ + f"{embedding_route.provider}:{embedding_route.model}", + f"dimension={embedding_route.dimension}", + ] + if embedding_route.endpoint: + route_parts.append(f"endpoint={embedding_route.endpoint}") + if embedding_route.credential_env: + route_parts.append(f"auth={embedding_route.credential_env}") + if route_issue: + route_parts.append(route_issue) + embedding_checks = [ + ("Embedding route", route_ok, "; ".join(route_parts)), + ( + embedding_label, + _check_module(embedding_module), + "installed" if _check_module(embedding_module) else "missing", + ), + ( + "FAISS", + _check_module("faiss"), + "installed" if _check_module("faiss") else "missing", + ), + ] + except CLIError as exc: + embedding_checks = [ + ("Embedding route", False, str(exc)), + ( + "FAISS", + _check_module("faiss"), + "installed" if _check_module("faiss") else "missing", + ), + ] rows = { "core": [ ("Python >= 3.10", py_ok, sys.version.split()[0]), @@ -620,18 +949,7 @@ def _doctor_rows( str(frontend) if frontend is not None else "missing", ), ], - "semantic": [ - ( - "sentence-transformers", - _check_module("sentence_transformers"), - ("installed" if _check_module("sentence_transformers") else "missing"), - ), - ( - "FAISS", - _check_module("faiss"), - "installed" if _check_module("faiss") else "missing", - ), - ], + "semantic": embedding_checks, "graph": [ ( "igraph", @@ -685,28 +1003,6 @@ def _model_probe_error(exc: BaseException, *, api_key: str | None) -> str: def _probe_doctor_model( args: argparse.Namespace, ) -> list[tuple[str, bool, str]]: - model = ( - args.model - or os.environ.get("CODENIB_DEMO_WIKI_MODEL") - or os.environ.get("CODENIB_DEMO_MODEL") - ) - if not model: - return [ - ("Model text probe", False, "set --model or CODENIB_DEMO_MODEL"), - ("Model tool probe", False, "skipped: model is not configured"), - ("Model structured probe", False, "skipped: model is not configured"), - ] - api_base = ( - args.api_base - or os.environ.get("CODENIB_DEMO_WIKI_API_BASE") - or os.environ.get("CODENIB_DEMO_API_BASE") - ) - api_key = ( - os.environ.get(args.api_key_env) - if args.api_key_env - else os.environ.get("CODENIB_DEMO_WIKI_API_KEY") - or os.environ.get("CODENIB_DEMO_API_KEY") - ) options = _model_options_for_args( args, include_wiki_environment=( @@ -714,12 +1010,20 @@ def _probe_doctor_model( and bool(os.environ.get("CODENIB_DEMO_WIKI_MODEL")) ), ) - if args.api_key_env and not api_key: - detail = f"{args.api_key_env} is unset or empty" + try: + backend = _model_backend_for_args(args, options=options) + except CLIError as exc: + detail = str(exc) return [ ("Model text probe", False, detail), - ("Model tool probe", False, "skipped: credentials are missing"), - ("Model structured probe", False, "skipped: credentials are missing"), + ("Model tool probe", False, "skipped: model route is invalid"), + ("Model structured probe", False, "skipped: model route is invalid"), + ] + if backend is None: + return [ + ("Model text probe", False, "set --model or CODENIB_DEMO_MODEL"), + ("Model tool probe", False, "skipped: model is not configured"), + ("Model structured probe", False, "skipped: model is not configured"), ] try: from pydantic import BaseModel @@ -729,11 +1033,11 @@ def _probe_doctor_model( probe_options = dict(options) probe_options.setdefault("timeout", 20) llm = LiteLLMChat( - model=model, + model=backend.model, temperature=0.0, max_tokens=32, - api_base=api_base, - api_key=api_key, + api_base=backend.api_base, + api_key=backend.api_key, extra_kwargs=probe_options, retry=RetryConfig(max_retries=0), ) @@ -741,7 +1045,7 @@ def _probe_doctor_model( [{"role": "user", "content": "Reply with OK."}], ) except Exception as exc: # noqa: BLE001 - diagnostic result - detail = _model_probe_error(exc, api_key=api_key) + detail = _model_probe_error(exc, api_key=backend.api_key) return [ ("Model text probe", False, detail), ("Model tool probe", False, "skipped: text completion failed"), @@ -807,7 +1111,7 @@ def _probe_doctor_model( ( "Model tool probe", False, - _model_probe_error(exc, api_key=api_key), + _model_probe_error(exc, api_key=backend.api_key), ) ) @@ -835,12 +1139,63 @@ class ProbeResponse(BaseModel): ( "Model structured probe", False, - _model_probe_error(exc, api_key=api_key), + _model_probe_error(exc, api_key=backend.api_key), ) ) return checks +def _probe_doctor_embedding( + args: argparse.Namespace, +) -> tuple[str, bool, str]: + api_key = None + store = None + try: + route = _embedding_route_for_args(args) + _check_view_dependencies(["vector"], embedding_provider=route.provider) + backend_kwargs = route.embedding_backend_kwargs() + client_kwargs = route.client_kwargs() + api_key = client_kwargs.get("api_key") + if api_key is None and route.provider != "huggingface": + api_key = os.environ.get("CODENIB_EMBEDDING_API_KEY") + if api_key: + client_kwargs["api_key"] = api_key + backend_kwargs.update(client_kwargs) + + from .index.embedding.vector_store import CodeVectorStore + + store = CodeVectorStore( + embedding_model=route.model, + embedding_provider=route.provider, + dimension=route.dimension, + **backend_kwargs, + ) + actual = store.dimension + if actual != route.dimension: + return ( + "Embedding probe", + False, + f"expected dimension {route.dimension}, received {actual}", + ) + return ( + "Embedding probe", + True, + f"vector received; dimension={actual}", + ) + except Exception as exc: # noqa: BLE001 - diagnostic result + return ( + "Embedding probe", + False, + _model_probe_error(exc, api_key=api_key), + ) + finally: + if store is not None: + try: + store.close() + except Exception: # noqa: BLE001 - best-effort diagnostic cleanup + pass + + def _run_doctor(args: argparse.Namespace) -> int: required = set(args.require or ["core"]) rows = _doctor_rows(args) @@ -853,6 +1208,8 @@ def _run_doctor(args: argparse.Namespace) -> int: graph_report = diagnose_graph_setup(repo_path, languages) if args.probe_model: rows["agent"].extend(_probe_doctor_model(args)) + if args.probe_embedding: + rows["semantic"].append(_probe_doctor_embedding(args)) failed_required = False print(f"CodeNib {package_version()}") for group, checks in rows.items(): @@ -893,6 +1250,32 @@ def _run_doctor(args: argparse.Namespace) -> int: return 1 if failed_required else 0 +def _add_embedding_route_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--embedding-provider", + help="embedding backend: huggingface or openai", + ) + parser.add_argument( + "--embedding-model", + help="embedding model id exposed by the selected backend", + ) + parser.add_argument( + "--embedding-dimension", + type=int, + help="vector width produced by the embedding model", + ) + parser.add_argument( + "--embedding-endpoint", + "--embedding-base-url", + dest="embedding_endpoint", + help="API base for a BYO OpenAI-compatible embedding service", + ) + parser.add_argument( + "--embedding-api-key-env", + help="environment variable containing the embedding API key", + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="codenib", @@ -930,6 +1313,7 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="rebuild instead of incrementally updating an existing manifest", ) + _add_embedding_route_arguments(index_parser) index_parser.set_defaults(handler=_run_index) wiki_parser = subparsers.add_parser( @@ -942,6 +1326,7 @@ def build_parser() -> argparse.ArgumentParser: wiki_parser.add_argument("--language", action="append", default=[]) wiki_parser.add_argument("--view", action="append", default=[]) wiki_parser.add_argument("--rebuild", action="store_true") + _add_embedding_route_arguments(wiki_parser) wiki_parser.add_argument( "--no-index", action="store_true", @@ -958,6 +1343,10 @@ def build_parser() -> argparse.ArgumentParser: "--model", help="LiteLLM model string used for Wiki generation and Ask", ) + wiki_parser.add_argument( + "--model-provider", + help="canonicalize the model through openai or another LiteLLM provider", + ) wiki_parser.add_argument( "--api-base", help="optional OpenAI-compatible API base for the configured model", @@ -1070,6 +1459,10 @@ def build_parser() -> argparse.ArgumentParser: "--model", help="LiteLLM model string to validate", ) + doctor_parser.add_argument( + "--model-provider", + help="canonicalize the model through openai or another LiteLLM provider", + ) doctor_parser.add_argument( "--api-base", help="optional OpenAI-compatible API base to validate", @@ -1095,6 +1488,12 @@ def build_parser() -> argparse.ArgumentParser: "after validating configuration" ), ) + _add_embedding_route_arguments(doctor_parser) + doctor_parser.add_argument( + "--probe-embedding", + action="store_true", + help="send one embedding request and verify the returned vector width", + ) doctor_parser.set_defaults(handler=_run_doctor) return parser diff --git a/codenib/web/config.py b/codenib/web/config.py index 4bc3b842..ad2fc6b7 100644 --- a/codenib/web/config.py +++ b/codenib/web/config.py @@ -27,6 +27,7 @@ validate_model_options, ) from ..paths import QA_DATA_DIRNAME, REPO_INDEX_DIRNAME +from ..provider_routes import normalize_provider DEFAULT_CONFIG_PATH = "qa_config.yaml" CACHE_DIR_NAME = REPO_INDEX_DIRNAME @@ -310,9 +311,9 @@ def load_config(path: Optional[str] = None) -> QAConfig: if os.environ.get("CODENIB_EMBEDDING_API_KEY"): cfg.embedding_api_key = os.environ["CODENIB_EMBEDDING_API_KEY"] - cfg.embedding_provider = cfg.embedding_provider.strip().lower() + cfg.embedding_provider = normalize_provider(cfg.embedding_provider) if cfg.embedding_provider not in {"huggingface", "openai"}: - raise ValueError("embedding_provider must be either 'huggingface' or 'openai'") + raise ValueError("embedding_provider must be huggingface or openai") return cfg diff --git a/codenib/web/local.py b/codenib/web/local.py index 34eb374e..3b05e457 100644 --- a/codenib/web/local.py +++ b/codenib/web/local.py @@ -112,6 +112,7 @@ def prepare_local_wiki( model: str | None = None, api_base: str | None = None, api_key_env: str | None = None, + embedding_api_key_env: str | None = None, model_options: Mapping[str, Any] | None = None, ) -> LocalWiki: """Write the registry and config consumed by the existing Wiki service.""" @@ -182,6 +183,14 @@ def prepare_local_wiki( f"API key environment variable is unset or empty: {api_key_env}" ) runtime_env["CODENIB_DEMO_API_KEY"] = api_key + if embedding_api_key_env: + embedding_api_key = os.environ.get(embedding_api_key_env) + if not embedding_api_key: + raise ValueError( + "Embedding API key environment variable is unset or empty: " + f"{embedding_api_key_env}" + ) + runtime_env["CODENIB_EMBEDDING_API_KEY"] = embedding_api_key return LocalWiki( repo_path=repo_path, diff --git a/docs/quickstart.md b/docs/quickstart.md index 63e0b3f5..d749f059 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -125,6 +125,32 @@ codenib wiki /path/to/repository --preset semantic The semantic preset downloads CodeRankEmbed on first use. CodeNib pins the built-in model to an immutable revision and enables remote model code only for that revision; caller-supplied models or revisions are not trusted implicitly. +To keep embeddings out of the local process, use a BYO OpenAI-compatible +embedding service: + +```bash +pip install "codenib[semantic-remote]" +export EMBEDDING_API_KEY=... +codenib doctor --require semantic \ + --embedding-provider openai \ + --embedding-endpoint https://inference.example.com/v1 \ + --embedding-api-key-env EMBEDDING_API_KEY \ + --probe-embedding +codenib wiki . --preset semantic \ + --embedding-provider openai \ + --embedding-endpoint https://inference.example.com/v1 \ + --embedding-api-key-env EMBEDDING_API_KEY +``` + +The remote default is `text-embedding-3-small` with dimension 1536. Select +another model with `--embedding-model` and declare its vector width with +`--embedding-dimension`; omit `--embedding-api-key-env` only when the endpoint +is intentionally unauthenticated. +Provider, model, endpoint, dimension, and vector-shaping options become part of +the vector artifact identity. Credentials, retries, timeouts, and batching stay +process-local, and CodeNib refuses to reopen an artifact through a different +provider or endpoint. + The `graph` extra supplies the Python graph and protobuf runtimes, while each repository language still needs its own SCIP/LSP executable. Check the exact repository instead of testing for an unrelated tool: @@ -176,6 +202,10 @@ codenib wiki . --generate \ --api-key-env LOCAL_LLM_KEY ``` +CodeNib passes BYO credentials only to the running client. They are never +written to `repo_manifest.json`, vector configuration, Wiki caches, or a static +Pages export. + Provider-native LiteLLM routes use their normal model prefix and credentials: ```bash diff --git a/docs/web_demo.md b/docs/web_demo.md index 2abfa16c..30735d9a 100644 --- a/docs/web_demo.md +++ b/docs/web_demo.md @@ -115,11 +115,18 @@ environment variables beat the YAML (`load_config()` in | `wiki_model_options` | `CODENIB_DEMO_WIKI_MODEL_OPTIONS` | Nested overrides applied only to Wiki, narration, and edge-label calls | | `data_dir` | `CODENIB_DEMO_DATA_DIR` | Where checked-out repos, indexes, and the registry live (default `.codenib_qa/`) | | `prebuilt_dir` | `CODENIB_DEMO_PREBUILT_DIR` | Read-only tree of pre-built per-instance artifacts (see above) | -| `embedding_provider` | `CODENIB_EMBEDDING_PROVIDER` | `huggingface` (in-process, default) or `openai` (OpenAI-compatible endpoint) | +| `embedding_provider` | `CODENIB_EMBEDDING_PROVIDER` | `huggingface` (in-process) or `openai` (BYO endpoint) | | `embedding_model` / `embedding_base_url` / `embedding_api_key` | `CODENIB_EMBEDDING_MODEL` / `CODENIB_EMBEDDING_BASE_URL` / `CODENIB_EMBEDDING_API_KEY` | Embedding model plus the endpoint and credential for the remote provider | | `edge_labels` | `CODENIB_EDGE_LABELS` | Opt-in LLM-written edge phrases in the graph view (off by default; each first-seen edge costs one small LLM call, then cached) | | `edge_label_model` | `CODENIB_EDGE_MODEL` | Optional cheaper model for the short edge-label calls | +The manifest's embedding route is authoritative when the service reopens a +vector view. Runtime configuration may provide its credential, but cannot swap +the artifact to another provider, model, dimension, or endpoint. A BYO +credential can be copied into `CODENIB_EMBEDDING_API_KEY` through the +`codenib wiki --embedding-api-key-env` option. No credential is persisted in +the manifest or vector-store configuration. + When `wiki_model` and `model` use the same LiteLLM provider prefix, Wiki may reuse `model_api_base` and `model_api_key`. A different provider never inherits the Ask endpoint or credential: configure `wiki_api_base` / `wiki_api_key`, or diff --git a/pyproject.toml b/pyproject.toml index 21a7e832..b4229a19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,11 @@ semantic = [ "openai>=1.0.0", "sentence-transformers>=2.2.0", ] +semantic-remote = [ + "faiss-cpu>=1.7.0", + "numpy>=1.24.0", + "openai>=1.0.0", +] # Vertex AI backends (``vertex_ai/...`` model strings, via gcloud ADC). # Keep the provider SDK coupled to LiteLLM's own compatibility constraint # instead of maintaining a second, looser google-cloud-aiplatform pin here. diff --git a/test/test_cli.py b/test/test_cli.py index 2ad07fc8..7467b754 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -59,6 +59,8 @@ def test_doctor_parser_accepts_model_backend_options() -> None: "agent", "--model", "openai/local-model", + "--model-provider", + "openai", "--api-base", "http://localhost:4000/v1", "--api-key-env", @@ -72,6 +74,7 @@ def test_doctor_parser_accepts_model_backend_options() -> None: ) assert args.model == "openai/local-model" + assert args.model_provider == "openai" assert args.api_base == "http://localhost:4000/v1" assert args.api_key_env == "LOCAL_LLM_KEY" assert args.model_option == [ @@ -81,6 +84,33 @@ def test_doctor_parser_accepts_model_backend_options() -> None: assert args.probe_model is True +def test_index_parser_accepts_remote_embedding_route() -> None: + args = cli.build_parser().parse_args( + [ + "index", + ".", + "--preset", + "semantic", + "--embedding-provider", + "openai", + "--embedding-model", + "text-embedding-3-small", + "--embedding-dimension", + "1536", + "--embedding-endpoint", + "https://inference.example.test/v1", + "--embedding-api-key-env", + "MODELS_TOKEN", + ] + ) + + assert args.embedding_provider == "openai" + assert args.embedding_model == "text-embedding-3-small" + assert args.embedding_dimension == 1536 + assert args.embedding_endpoint == "https://inference.example.test/v1" + assert args.embedding_api_key_env == "MODELS_TOKEN" + + def test_doctor_parser_accepts_repository_graph_context() -> None: args = cli.build_parser().parse_args( [ @@ -303,6 +333,177 @@ def test_cli_model_options_layer_environment_and_flags( } +def test_openai_chat_route_maps_to_litellm_without_storing_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("MODELS_TOKEN", "runtime-secret") + args = cli.build_parser().parse_args( + [ + "doctor", + "--model-provider", + "openai", + "--model", + "gpt-4.1", + "--api-base", + "https://inference.example.test/v1", + "--api-key-env", + "MODELS_TOKEN", + ] + ) + + backend = cli._model_backend_for_args(args) + + assert backend is not None + assert backend.model == "openai/gpt-4.1" + assert backend.api_base == "https://inference.example.test/v1" + assert backend.api_key == "runtime-secret" + assert backend.auth_source == "MODELS_TOKEN" + assert "runtime-secret" not in repr(backend) + + +def test_remote_embedding_defaults_and_requires_dimension_for_custom_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") + default_args = cli.build_parser().parse_args( + ["index", "--embedding-provider", "openai"] + ) + + route = cli._embedding_route_for_args(default_args) + + assert route.model == "text-embedding-3-small" + assert route.dimension == 1536 + custom_args = cli.build_parser().parse_args( + [ + "index", + "--embedding-provider", + "openai", + "--embedding-model", + "vendor/custom-model", + ] + ) + with pytest.raises(cli.CLIError, match="embedding-dimension"): + cli._embedding_route_for_args(custom_args) + + +def test_embedding_dimension_rejects_boolean_values() -> None: + with pytest.raises(cli.CLIError, match="positive integer"): + cli._optional_int(True, source="embedding dimension") + + +def test_openai_embedding_reports_missing_credential_without_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + args = cli.build_parser().parse_args(["index", "--embedding-provider", "openai"]) + + route = cli._embedding_route_for_args(args) + + with pytest.raises(ValueError) as raised: + route.credential() + assert str(raised.value) == ( + "credential environment variable is unset or empty: OPENAI_API_KEY" + ) + + +def test_fast_index_does_not_resolve_unused_embedding_route( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "sample.py").write_text("VALUE = 1\n") + monkeypatch.setenv("CODENIB_EMBEDDING_PROVIDER", "not a provider") + captured = {} + + def fake_index(repo_path, **kwargs): + captured.update(repo_path=repo_path, **kwargs) + return ( + SimpleNamespace( + repo_path=str(repo_path), + languages=kwargs["languages"], + indexes={"bm25": SimpleNamespace(status="fresh", metadata={})}, + ), + [], + ) + + monkeypatch.setattr(cli, "index_repository", fake_index) + + assert cli.run(["index", str(tmp_path), "--preset", "fast"]) == 0 + assert captured["views"] == ["bm25"] + assert "embedding_provider" not in captured + + +def test_remote_semantic_dependency_check_does_not_require_sentence_transformers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked = [] + + def check(module): + checked.append(module) + return module in {"faiss", "openai"} + + monkeypatch.setattr(cli, "_check_module", check) + + cli._check_view_dependencies( + ["vector"], + embedding_provider="openai", + ) + + assert "faiss" in checked + assert "openai" in checked + assert "sentence_transformers" not in checked + + +def test_doctor_reports_remote_embedding_route_without_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "doctor-secret") + monkeypatch.setattr( + cli, "_check_module", lambda module: module in {"faiss", "openai"} + ) + args = cli.build_parser().parse_args(["doctor", "--embedding-provider", "openai"]) + + semantic = cli._doctor_rows(args)["semantic"] + checks = {label: (ok, detail) for label, ok, detail in semantic} + + assert checks["Embedding route"][0] is True + assert "openai:text-embedding-3-small" in checks["Embedding route"][1] + assert "auth=OPENAI_API_KEY" in checks["Embedding route"][1] + assert "doctor-secret" not in str(semantic) + assert checks["OpenAI SDK"] == (True, "installed") + assert "sentence-transformers" not in checks + + +def test_doctor_embedding_probe_uses_resolved_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import codenib.index.embedding.vector_store as vector_module + + captured = {} + + class FakeStore: + def __init__(self, **kwargs): + captured.update(kwargs) + self.dimension = kwargs["dimension"] + + def close(self): + captured["closed"] = True + + monkeypatch.setenv("OPENAI_API_KEY", "probe-secret") + monkeypatch.setattr(cli, "_check_module", lambda _module: True) + monkeypatch.setattr(vector_module, "CodeVectorStore", FakeStore) + args = cli.build_parser().parse_args( + ["doctor", "--embedding-provider", "openai", "--probe-embedding"] + ) + + check = cli._probe_doctor_embedding(args) + + assert check == ("Embedding probe", True, "vector received; dimension=1536") + assert captured["embedding_provider"] == "openai" + assert "base_url" not in captured + assert captured["api_key"] == "probe-secret" + assert captured["closed"] is True + + def test_detect_languages_orders_by_file_count_and_skips_generated_dirs( tmp_path: Path, ) -> None: @@ -432,6 +633,52 @@ def test_semantic_preset_reports_required_extra( assert "codenib[semantic]" in capsys.readouterr().err +def test_semantic_index_passes_resolved_openai_route( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "sample.py").write_text("def sample():\n return 1\n") + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") + monkeypatch.setattr(cli, "_check_module", lambda _module: True) + captured = {} + + def fake_index(repo_path, **kwargs): + captured.update(repo_path=repo_path, **kwargs) + entries = { + view: SimpleNamespace(status="fresh", metadata={}) + for view in kwargs["views"] + } + return ( + SimpleNamespace( + repo_path=str(repo_path), + languages=kwargs["languages"], + indexes=entries, + ), + [], + ) + + monkeypatch.setattr(cli, "index_repository", fake_index) + + result = cli.run( + [ + "index", + str(tmp_path), + "--preset", + "semantic", + "--embedding-provider", + "openai", + ] + ) + + assert result == 0 + assert captured["embedding_provider"] == "openai" + assert captured["embedding_model"] == "text-embedding-3-small" + assert captured["embedding_dimension"] == 1536 + assert captured["embedding_endpoint"] is None + assert captured["embedding_credential_env"] == "OPENAI_API_KEY" + assert "runtime-secret" not in str(captured) + + def test_graph_preset_selects_bm25_and_symbol_graph( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -679,6 +926,28 @@ def test_prepare_generated_wiki_keeps_secret_out_of_config( } +def test_prepare_local_wiki_keeps_embedding_secret_process_local( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codenib.compiler.manifest import RepoManifest + + manifest_path = tmp_path / ".codenib_cache" / "repo_manifest.json" + manifest_path.parent.mkdir() + RepoManifest(repo_path=str(tmp_path), languages=["python"]).save(str(manifest_path)) + monkeypatch.setenv("EMBEDDING_KEY", "embedding-secret") + + local = prepare_local_wiki( + tmp_path, + manifest_path, + frontend_port=3000, + embedding_api_key_env="EMBEDDING_KEY", + ) + + assert "embedding-secret" not in local.config_path.read_text() + assert local.runtime_env["CODENIB_EMBEDDING_API_KEY"] == "embedding-secret" + + def test_wiki_audit_exits_without_starting_frontend( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -723,6 +992,112 @@ def test_wiki_audit_exits_without_starting_frontend( assert "Result: PASS" in output +def test_wiki_generate_resolves_openai_route( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codenib.compiler.manifest import IndexEntry, RepoManifest + + (tmp_path / "sample.py").write_text("def sample():\n return 1\n") + manifest_path = tmp_path / "repo_manifest.json" + RepoManifest( + repo_path=str(tmp_path), + languages=["python"], + indexes={ + "bm25": IndexEntry( + index_type="bm25", + path=str(tmp_path / "bm25"), + built_at="2026-08-04T00:00:00+00:00", + built_at_epoch=0.0, + status="fresh", + ) + }, + ).save(manifest_path) + captured = {} + local = SimpleNamespace(runtime_env={}) + monkeypatch.setenv("MODELS_TOKEN", "runtime-secret") + monkeypatch.setattr(cli, "_check_module", lambda _module: True) + monkeypatch.setattr(cli, "resolve_manifest_path", lambda _value: manifest_path) + + def prepare(*_args, **kwargs): + captured.update(kwargs) + return local + + monkeypatch.setattr("codenib.web.local.prepare_local_wiki", prepare) + monkeypatch.setattr( + "codenib.web.launcher.launch_local_wiki", + lambda *_args, **_kwargs: 0, + ) + + result = cli.run( + [ + "wiki", + str(tmp_path), + "--no-index", + "--generate", + "--model-provider", + "openai", + "--model", + "gpt-4.1", + "--api-base", + "https://inference.example.test/v1", + "--api-key-env", + "MODELS_TOKEN", + "--no-open", + ] + ) + + assert result == 0 + assert captured["model"] == "openai/gpt-4.1" + assert captured["api_base"] == "https://inference.example.test/v1" + assert captured["api_key_env"] == "MODELS_TOKEN" + assert "runtime-secret" not in str(captured) + + +def test_wiki_rejects_embedding_route_that_disagrees_with_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from codenib.compiler.index_builders import VectorIndexBuilder + from codenib.compiler.manifest import IndexEntry, RepoManifest + + (tmp_path / "sample.py").write_text("VALUE = 1\n") + manifest_path = tmp_path / "repo_manifest.json" + RepoManifest( + repo_path=str(tmp_path), + languages=["python"], + indexes={ + "vector": IndexEntry( + index_type="vector", + path=str(tmp_path / "vector"), + built_at="2026-08-04T00:00:00+00:00", + built_at_epoch=0.0, + status="fresh", + config=VectorIndexBuilder().artifact_identity(), + ) + }, + ).save(manifest_path) + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") + monkeypatch.setattr(cli, "_check_module", lambda _module: True) + monkeypatch.setattr(cli, "resolve_manifest_path", lambda _value: manifest_path) + + result = cli.run( + [ + "wiki", + str(tmp_path), + "--no-index", + "--embedding-provider", + "openai", + ] + ) + + assert result == 2 + error = capsys.readouterr().err + assert "does not match the current vector artifact" in error + assert "runtime-secret" not in error + + def test_installed_package_frontend_is_prebuilt( tmp_path: Path, ) -> None: diff --git a/test/test_cli_remote_embeddings.py b/test/test_cli_remote_embeddings.py new file mode 100644 index 00000000..1e639fa9 --- /dev/null +++ b/test/test_cli_remote_embeddings.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +import pytest + +from codenib import cli + + +class _EmbeddingHandler(BaseHTTPRequestHandler): + requests: list[dict] = [] + + def do_POST(self) -> None: # noqa: N802 - stdlib handler contract + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length)) + self.__class__.requests.append( + { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "payload": payload, + } + ) + inputs = payload["input"] + if isinstance(inputs, str): + inputs = [inputs] + response = { + "object": "list", + "model": payload["model"], + "data": [ + { + "object": "embedding", + "index": index, + "embedding": [1.0, float(index), 0.0, 0.0], + } + for index, _ in enumerate(inputs) + ], + "usage": {"prompt_tokens": len(inputs), "total_tokens": len(inputs)}, + } + encoded = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, _format: str, *_args) -> None: + return + + +def test_openai_semantic_build_uses_remote_sdk_without_sentence_transformers( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "sample.py").write_text( + "def answer(value: int) -> int:\n return value + 1\n" + ) + monkeypatch.setenv("CODENIB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("EMBEDDING_API_KEY", "runtime-token") + monkeypatch.setitem(sys.modules, "sentence_transformers", None) + _EmbeddingHandler.requests = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), _EmbeddingHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + endpoint = f"http://127.0.0.1:{server.server_port}/inference" + try: + manifest, failed = cli.index_repository( + repo, + languages=["python"], + views=["vector"], + embedding_provider="openai", + embedding_model="text-embedding-3-small", + embedding_dimension=4, + embedding_endpoint=endpoint, + embedding_credential_env="EMBEDDING_API_KEY", + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert failed == [] + entry = manifest.indexes["vector"] + assert entry.config["embedding_provider"] == "openai" + assert entry.config["embedding_endpoint"] == endpoint + assert entry.config["embedding_dimension"] == 4 + serialized = json.dumps(entry.config, sort_keys=True) + assert "runtime-token" not in serialized + assert "EMBEDDING_API_KEY" not in serialized + assert _EmbeddingHandler.requests + assert all( + request["path"] == "/inference/embeddings" + for request in _EmbeddingHandler.requests + ) + assert all( + request["authorization"] == "Bearer runtime-token" + for request in _EmbeddingHandler.requests + ) diff --git a/test/test_release_metadata.py b/test/test_release_metadata.py index 457e4d7a..a20bcec5 100644 --- a/test/test_release_metadata.py +++ b/test/test_release_metadata.py @@ -66,12 +66,15 @@ def test_optional_capabilities_have_named_extras() -> None: "graph", "mcp", "semantic", + "semantic-remote", "vertex", "zoekt", "full", } <= set(extras) assert "litellm" in _names(extras["agent"]) assert {"faiss-cpu", "sentence-transformers"} <= _names(extras["semantic"]) + assert {"faiss-cpu", "numpy", "openai"} <= _names(extras["semantic-remote"]) + assert "sentence-transformers" not in _names(extras["semantic-remote"]) assert "igraph" in _names(extras["graph"]) assert "mcp" in _names(extras["mcp"])