From 1a9ec46089226e642964982f932463a12c61bf20 Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Tue, 4 Aug 2026 22:22:39 -0700 Subject: [PATCH 1/3] feat(cli): expose hosted inference routes Add provider-aware semantic indexing, manifest-authoritative Wiki reopening, redacted doctor probes, and process-local credential delivery for GitHub Models and BYO OpenAI-compatible services.\n\nVerify remote embedding construction through a local OpenAI-compatible server and cover route mismatch, dependency, packaging, and secret-regression boundaries. --- codenib/cli.py | 625 +++++++++++++++++++++++------ codenib/web/config.py | 9 +- codenib/web/local.py | 9 + pyproject.toml | 5 + test/test_cli.py | 369 +++++++++++++++++ test/test_cli_remote_embeddings.py | 108 +++++ test/test_release_metadata.py | 3 + 7 files changed, 1013 insertions(+), 115 deletions(-) create mode 100644 test/test_cli_remote_embeddings.py diff --git a/codenib/cli.py b/codenib/cli.py index aa480f8b..713fbf1e 100644 --- a/codenib/cli.py +++ b/codenib/cli.py @@ -14,10 +14,18 @@ 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 ( + GITHUB_MODELS_PROVIDER, + InferenceRoute, + normalize_provider, + resolve_embedding_artifact_route, + resolve_inference_route, +) from .repository_filters import DEFAULT_IGNORED_DIRS _PRESET_VIEWS = { @@ -26,12 +34,179 @@ "graph": ("bm25", "symbol_graph"), "full": ("bm25", "vector", "symbol_graph", "zoekt"), } +_REMOTE_EMBEDDING_DEFAULTS = { + GITHUB_MODELS_PROVIDER: ("openai/text-embedding-3-small", 1536), + "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 +316,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 +405,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 +525,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 +584,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 +624,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 +667,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 +715,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 +767,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 +794,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 +801,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 +845,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 +951,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 +1005,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 +1012,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 +1035,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 +1047,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 +1113,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 +1141,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 +1210,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 +1252,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, github_models, or openai", + ) + parser.add_argument( + "--embedding-model", + help="embedding model id; GitHub Models uses publisher/model", + ) + 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 +1315,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 +1328,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 +1345,10 @@ def build_parser() -> argparse.ArgumentParser: "--model", help="LiteLLM model string used for Wiki generation and Ask", ) + wiki_parser.add_argument( + "--model-provider", + help="resolve the model through github_models, openai, or a LiteLLM provider", + ) wiki_parser.add_argument( "--api-base", help="optional OpenAI-compatible API base for the configured model", @@ -1070,6 +1461,10 @@ def build_parser() -> argparse.ArgumentParser: "--model", help="LiteLLM model string to validate", ) + doctor_parser.add_argument( + "--model-provider", + help="resolve the model through github_models, openai, or a LiteLLM provider", + ) doctor_parser.add_argument( "--api-base", help="optional OpenAI-compatible API base to validate", @@ -1095,6 +1490,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..7b4ae303 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,11 @@ 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() - if cfg.embedding_provider not in {"huggingface", "openai"}: - raise ValueError("embedding_provider must be either 'huggingface' or 'openai'") + cfg.embedding_provider = normalize_provider(cfg.embedding_provider) + if cfg.embedding_provider not in {"huggingface", "openai", "github_models"}: + raise ValueError( + "embedding_provider must be huggingface, openai, or github_models" + ) 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/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..5fe56a21 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,30 @@ 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", + "github-models", + "--embedding-model", + "openai/text-embedding-3-small", + "--embedding-dimension", + "1536", + "--embedding-api-key-env", + "MODELS_TOKEN", + ] + ) + + assert args.embedding_provider == "github-models" + assert args.embedding_model == "openai/text-embedding-3-small" + assert args.embedding_dimension == 1536 + 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 +330,178 @@ def test_cli_model_options_layer_environment_and_flags( } +def test_github_models_chat_route_maps_to_litellm_without_storing_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "runtime-secret") + args = cli.build_parser().parse_args( + [ + "doctor", + "--model-provider", + "github_models", + "--model", + "openai/gpt-4.1", + ] + ) + + backend = cli._model_backend_for_args(args) + + assert backend is not None + assert backend.model == "openai/openai/gpt-4.1" + assert backend.api_base == "https://models.github.ai/inference" + assert backend.api_key == "runtime-secret" + assert backend.auth_source == "GITHUB_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("GITHUB_TOKEN", "runtime-secret") + default_args = cli.build_parser().parse_args( + ["index", "--embedding-provider", "github_models"] + ) + + route = cli._embedding_route_for_args(default_args) + + assert route.model == "openai/text-embedding-3-small" + assert route.dimension == 1536 + custom_args = cli.build_parser().parse_args( + [ + "index", + "--embedding-provider", + "github_models", + "--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_github_models_embedding_reports_missing_credential_without_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + args = cli.build_parser().parse_args( + ["index", "--embedding-provider", "github_models"] + ) + + 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: GITHUB_TOKEN" + ) + + +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="github_models", + ) + + 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("GITHUB_TOKEN", "doctor-secret") + monkeypatch.setattr( + cli, "_check_module", lambda module: module in {"faiss", "openai"} + ) + args = cli.build_parser().parse_args( + ["doctor", "--embedding-provider", "github_models"] + ) + + semantic = cli._doctor_rows(args)["semantic"] + checks = {label: (ok, detail) for label, ok, detail in semantic} + + assert checks["Embedding route"][0] is True + assert "github_models:openai/text-embedding-3-small" in checks["Embedding route"][1] + assert "auth=GITHUB_TOKEN" 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("GITHUB_TOKEN", "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", "github_models", "--probe-embedding"] + ) + + check = cli._probe_doctor_embedding(args) + + assert check == ("Embedding probe", True, "vector received; dimension=1536") + assert captured["embedding_provider"] == "github_models" + assert captured["base_url"] == "https://models.github.ai/inference" + 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 +631,52 @@ def test_semantic_preset_reports_required_extra( assert "codenib[semantic]" in capsys.readouterr().err +def test_semantic_index_passes_resolved_github_models_route( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "sample.py").write_text("def sample():\n return 1\n") + monkeypatch.setenv("GITHUB_TOKEN", "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", + "github_models", + ] + ) + + assert result == 0 + assert captured["embedding_provider"] == "github_models" + assert captured["embedding_model"] == "openai/text-embedding-3-small" + assert captured["embedding_dimension"] == 1536 + assert captured["embedding_endpoint"] == "https://models.github.ai/inference" + assert captured["embedding_credential_env"] == "GITHUB_TOKEN" + assert "runtime-secret" not in str(captured) + + def test_graph_preset_selects_bm25_and_symbol_graph( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -679,6 +924,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 +990,108 @@ def test_wiki_audit_exits_without_starting_frontend( assert "Result: PASS" in output +def test_wiki_generate_resolves_github_models_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("GITHUB_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", + "github_models", + "--model", + "openai/gpt-4.1", + "--no-open", + ] + ) + + assert result == 0 + assert captured["model"] == "openai/openai/gpt-4.1" + assert captured["api_base"] == "https://models.github.ai/inference" + assert captured["api_key_env"] == "GITHUB_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("GITHUB_TOKEN", "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", + "github_models", + ] + ) + + 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..6249b698 --- /dev/null +++ b/test/test_cli_remote_embeddings.py @@ -0,0 +1,108 @@ +# 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, provider_routes + + +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_github_models_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("GITHUB_TOKEN", "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" + monkeypatch.setattr(provider_routes, "GITHUB_MODELS_BASE_URL", endpoint) + + try: + manifest, failed = cli.index_repository( + repo, + languages=["python"], + views=["vector"], + embedding_provider="github_models", + embedding_model="openai/text-embedding-3-small", + embedding_dimension=4, + embedding_endpoint=endpoint, + embedding_credential_env="GITHUB_TOKEN", + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert failed == [] + entry = manifest.indexes["vector"] + assert entry.config["embedding_provider"] == "github_models" + 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 "GITHUB_TOKEN" 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"]) From 35acad1b50f95028a44ef29a7be4d376ad47eaf0 Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Tue, 4 Aug 2026 22:22:48 -0700 Subject: [PATCH 2/3] docs(providers): document hosted inference routes Explain GitHub Models and BYO embedding/chat setup, workflow permission requirements, artifact compatibility identity, billing boundaries, and the guarantee that credentials never enter manifests or static exports. --- docs/quickstart.md | 41 +++++++++++++++++++++++++++++++++++++++++ docs/web_demo.md | 10 +++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 63e0b3f5..56f10783 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -125,6 +125,26 @@ 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 GitHub Models instead: + +```bash +pip install "codenib[semantic-remote]" +export GITHUB_TOKEN=... +codenib doctor --require semantic \ + --embedding-provider github_models --probe-embedding +codenib wiki . --preset semantic --embedding-provider github_models +``` + +The default hosted route uses `openai/text-embedding-3-small`. Select another +`publisher/model` with `--embedding-model` and declare its vector width with +`--embedding-dimension`. A BYO OpenAI-compatible service uses +`--embedding-provider openai --embedding-endpoint ...`; add +`--embedding-api-key-env` only when that service requires authentication. +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 +196,26 @@ codenib wiki . --generate \ --api-key-env LOCAL_LLM_KEY ``` +GitHub Models uses the token already available to a GitHub-hosted Action when +the workflow grants `models: read`, or a user token outside Actions: + +```bash +export GITHUB_TOKEN=... +codenib doctor --require agent \ + --model-provider github_models \ + --model openai/gpt-4.1 \ + --probe-model +codenib wiki . --generate \ + --model-provider github_models \ + --model openai/gpt-4.1 +``` + +GitHub Models usage is billed separately from GitHub Copilot; see GitHub's +[Models billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-models). +CodeNib passes the credential only to the running client. It is 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 @@ -198,6 +238,7 @@ Choose the route that matches the server actually receiving the request: | Backend | `--model` shape | Endpoint and authentication | | --- | --- | --- | +| GitHub Models | `publisher/model` with `--model-provider github_models` | Fixed GitHub Models inference endpoint; `GITHUB_TOKEN`, `GH_TOKEN`, or `--api-key-env` | | OpenAI | `openai/` | `OPENAI_API_KEY` | | Anthropic | `anthropic/` | `ANTHROPIC_API_KEY` | | OpenAI-compatible gateway or vLLM | `openai/` | `--api-base .../v1`; add `--api-key-env` only when the gateway requires it | diff --git a/docs/web_demo.md b/docs/web_demo.md index 2abfa16c..5f95f69d 100644 --- a/docs/web_demo.md +++ b/docs/web_demo.md @@ -115,11 +115,19 @@ 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), `openai` (BYO endpoint), or `github_models` | | `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. GitHub Models +routes discover `GITHUB_TOKEN` or `GH_TOKEN`; a custom 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 From a8fe56126eb303c72a7a52209e56f3c972ba5e98 Mon Sep 17 00:00:00 2001 From: fishmingyu <1661342068@qq.com> Date: Wed, 5 Aug 2026 00:15:16 -0700 Subject: [PATCH 3/3] fix(cli): retire GitHub Models provider options --- codenib/cli.py | 10 ++- codenib/web/config.py | 6 +- docs/quickstart.md | 47 +++++-------- docs/web_demo.md | 11 ++- test/test_cli.py | 104 +++++++++++++++-------------- test/test_cli_remote_embeddings.py | 18 +++-- 6 files changed, 92 insertions(+), 104 deletions(-) diff --git a/codenib/cli.py b/codenib/cli.py index 713fbf1e..90443515 100644 --- a/codenib/cli.py +++ b/codenib/cli.py @@ -20,7 +20,6 @@ from ._version import package_version from .provider_routes import ( - GITHUB_MODELS_PROVIDER, InferenceRoute, normalize_provider, resolve_embedding_artifact_route, @@ -35,7 +34,6 @@ "full": ("bm25", "vector", "symbol_graph", "zoekt"), } _REMOTE_EMBEDDING_DEFAULTS = { - GITHUB_MODELS_PROVIDER: ("openai/text-embedding-3-small", 1536), "openai": ("text-embedding-3-small", 1536), } @@ -1255,11 +1253,11 @@ def _run_doctor(args: argparse.Namespace) -> int: def _add_embedding_route_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--embedding-provider", - help="embedding backend: huggingface, github_models, or openai", + help="embedding backend: huggingface or openai", ) parser.add_argument( "--embedding-model", - help="embedding model id; GitHub Models uses publisher/model", + help="embedding model id exposed by the selected backend", ) parser.add_argument( "--embedding-dimension", @@ -1347,7 +1345,7 @@ def build_parser() -> argparse.ArgumentParser: ) wiki_parser.add_argument( "--model-provider", - help="resolve the model through github_models, openai, or a LiteLLM provider", + help="canonicalize the model through openai or another LiteLLM provider", ) wiki_parser.add_argument( "--api-base", @@ -1463,7 +1461,7 @@ def build_parser() -> argparse.ArgumentParser: ) doctor_parser.add_argument( "--model-provider", - help="resolve the model through github_models, openai, or a LiteLLM provider", + help="canonicalize the model through openai or another LiteLLM provider", ) doctor_parser.add_argument( "--api-base", diff --git a/codenib/web/config.py b/codenib/web/config.py index 7b4ae303..ad2fc6b7 100644 --- a/codenib/web/config.py +++ b/codenib/web/config.py @@ -312,10 +312,8 @@ def load_config(path: Optional[str] = None) -> QAConfig: cfg.embedding_api_key = os.environ["CODENIB_EMBEDDING_API_KEY"] cfg.embedding_provider = normalize_provider(cfg.embedding_provider) - if cfg.embedding_provider not in {"huggingface", "openai", "github_models"}: - raise ValueError( - "embedding_provider must be huggingface, openai, or github_models" - ) + if cfg.embedding_provider not in {"huggingface", "openai"}: + raise ValueError("embedding_provider must be huggingface or openai") return cfg diff --git a/docs/quickstart.md b/docs/quickstart.md index 56f10783..d749f059 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -125,21 +125,27 @@ 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 GitHub Models instead: +To keep embeddings out of the local process, use a BYO OpenAI-compatible +embedding service: ```bash pip install "codenib[semantic-remote]" -export GITHUB_TOKEN=... +export EMBEDDING_API_KEY=... codenib doctor --require semantic \ - --embedding-provider github_models --probe-embedding -codenib wiki . --preset semantic --embedding-provider github_models + --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 default hosted route uses `openai/text-embedding-3-small`. Select another -`publisher/model` with `--embedding-model` and declare its vector width with -`--embedding-dimension`. A BYO OpenAI-compatible service uses -`--embedding-provider openai --embedding-endpoint ...`; add -`--embedding-api-key-env` only when that service requires authentication. +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 @@ -196,25 +202,9 @@ codenib wiki . --generate \ --api-key-env LOCAL_LLM_KEY ``` -GitHub Models uses the token already available to a GitHub-hosted Action when -the workflow grants `models: read`, or a user token outside Actions: - -```bash -export GITHUB_TOKEN=... -codenib doctor --require agent \ - --model-provider github_models \ - --model openai/gpt-4.1 \ - --probe-model -codenib wiki . --generate \ - --model-provider github_models \ - --model openai/gpt-4.1 -``` - -GitHub Models usage is billed separately from GitHub Copilot; see GitHub's -[Models billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-models). -CodeNib passes the credential only to the running client. It is never written -to `repo_manifest.json`, vector configuration, Wiki caches, or a static Pages -export. +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: @@ -238,7 +228,6 @@ Choose the route that matches the server actually receiving the request: | Backend | `--model` shape | Endpoint and authentication | | --- | --- | --- | -| GitHub Models | `publisher/model` with `--model-provider github_models` | Fixed GitHub Models inference endpoint; `GITHUB_TOKEN`, `GH_TOKEN`, or `--api-key-env` | | OpenAI | `openai/` | `OPENAI_API_KEY` | | Anthropic | `anthropic/` | `ANTHROPIC_API_KEY` | | OpenAI-compatible gateway or vLLM | `openai/` | `--api-base .../v1`; add `--api-key-env` only when the gateway requires it | diff --git a/docs/web_demo.md b/docs/web_demo.md index 5f95f69d..30735d9a 100644 --- a/docs/web_demo.md +++ b/docs/web_demo.md @@ -115,18 +115,17 @@ 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), `openai` (BYO endpoint), or `github_models` | +| `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. GitHub Models -routes discover `GITHUB_TOKEN` or `GH_TOKEN`; a custom 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. +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 diff --git a/test/test_cli.py b/test/test_cli.py index 5fe56a21..7467b754 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -92,19 +92,22 @@ def test_index_parser_accepts_remote_embedding_route() -> None: "--preset", "semantic", "--embedding-provider", - "github-models", + "openai", "--embedding-model", - "openai/text-embedding-3-small", + "text-embedding-3-small", "--embedding-dimension", "1536", + "--embedding-endpoint", + "https://inference.example.test/v1", "--embedding-api-key-env", "MODELS_TOKEN", ] ) - assert args.embedding_provider == "github-models" - assert args.embedding_model == "openai/text-embedding-3-small" + 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" @@ -330,47 +333,51 @@ def test_cli_model_options_layer_environment_and_flags( } -def test_github_models_chat_route_maps_to_litellm_without_storing_key( +def test_openai_chat_route_maps_to_litellm_without_storing_key( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("GITHUB_TOKEN", "runtime-secret") + monkeypatch.setenv("MODELS_TOKEN", "runtime-secret") args = cli.build_parser().parse_args( [ "doctor", "--model-provider", - "github_models", + "openai", "--model", - "openai/gpt-4.1", + "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/openai/gpt-4.1" - assert backend.api_base == "https://models.github.ai/inference" + 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 == "GITHUB_TOKEN" + 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("GITHUB_TOKEN", "runtime-secret") + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") default_args = cli.build_parser().parse_args( - ["index", "--embedding-provider", "github_models"] + ["index", "--embedding-provider", "openai"] ) route = cli._embedding_route_for_args(default_args) - assert route.model == "openai/text-embedding-3-small" + assert route.model == "text-embedding-3-small" assert route.dimension == 1536 custom_args = cli.build_parser().parse_args( [ "index", "--embedding-provider", - "github_models", + "openai", "--embedding-model", "vendor/custom-model", ] @@ -384,21 +391,18 @@ def test_embedding_dimension_rejects_boolean_values() -> None: cli._optional_int(True, source="embedding dimension") -def test_github_models_embedding_reports_missing_credential_without_secret( +def test_openai_embedding_reports_missing_credential_without_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.delenv("GH_TOKEN", raising=False) - args = cli.build_parser().parse_args( - ["index", "--embedding-provider", "github_models"] - ) + 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: GITHUB_TOKEN" + "credential environment variable is unset or empty: OPENAI_API_KEY" ) @@ -441,7 +445,7 @@ def check(module): cli._check_view_dependencies( ["vector"], - embedding_provider="github_models", + embedding_provider="openai", ) assert "faiss" in checked @@ -452,20 +456,18 @@ def check(module): def test_doctor_reports_remote_embedding_route_without_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("GITHUB_TOKEN", "doctor-secret") + 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", "github_models"] - ) + 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 "github_models:openai/text-embedding-3-small" in checks["Embedding route"][1] - assert "auth=GITHUB_TOKEN" in checks["Embedding route"][1] + 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 @@ -486,18 +488,18 @@ def __init__(self, **kwargs): def close(self): captured["closed"] = True - monkeypatch.setenv("GITHUB_TOKEN", "probe-secret") + 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", "github_models", "--probe-embedding"] + ["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"] == "github_models" - assert captured["base_url"] == "https://models.github.ai/inference" + assert captured["embedding_provider"] == "openai" + assert "base_url" not in captured assert captured["api_key"] == "probe-secret" assert captured["closed"] is True @@ -631,12 +633,12 @@ def test_semantic_preset_reports_required_extra( assert "codenib[semantic]" in capsys.readouterr().err -def test_semantic_index_passes_resolved_github_models_route( +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("GITHUB_TOKEN", "runtime-secret") + monkeypatch.setenv("OPENAI_API_KEY", "runtime-secret") monkeypatch.setattr(cli, "_check_module", lambda _module: True) captured = {} @@ -664,16 +666,16 @@ def fake_index(repo_path, **kwargs): "--preset", "semantic", "--embedding-provider", - "github_models", + "openai", ] ) assert result == 0 - assert captured["embedding_provider"] == "github_models" - assert captured["embedding_model"] == "openai/text-embedding-3-small" + assert captured["embedding_provider"] == "openai" + assert captured["embedding_model"] == "text-embedding-3-small" assert captured["embedding_dimension"] == 1536 - assert captured["embedding_endpoint"] == "https://models.github.ai/inference" - assert captured["embedding_credential_env"] == "GITHUB_TOKEN" + assert captured["embedding_endpoint"] is None + assert captured["embedding_credential_env"] == "OPENAI_API_KEY" assert "runtime-secret" not in str(captured) @@ -990,7 +992,7 @@ def test_wiki_audit_exits_without_starting_frontend( assert "Result: PASS" in output -def test_wiki_generate_resolves_github_models_route( +def test_wiki_generate_resolves_openai_route( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1013,7 +1015,7 @@ def test_wiki_generate_resolves_github_models_route( ).save(manifest_path) captured = {} local = SimpleNamespace(runtime_env={}) - monkeypatch.setenv("GITHUB_TOKEN", "runtime-secret") + monkeypatch.setenv("MODELS_TOKEN", "runtime-secret") monkeypatch.setattr(cli, "_check_module", lambda _module: True) monkeypatch.setattr(cli, "resolve_manifest_path", lambda _value: manifest_path) @@ -1034,17 +1036,21 @@ def prepare(*_args, **kwargs): "--no-index", "--generate", "--model-provider", - "github_models", + "openai", "--model", - "openai/gpt-4.1", + "gpt-4.1", + "--api-base", + "https://inference.example.test/v1", + "--api-key-env", + "MODELS_TOKEN", "--no-open", ] ) assert result == 0 - assert captured["model"] == "openai/openai/gpt-4.1" - assert captured["api_base"] == "https://models.github.ai/inference" - assert captured["api_key_env"] == "GITHUB_TOKEN" + 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) @@ -1072,7 +1078,7 @@ def test_wiki_rejects_embedding_route_that_disagrees_with_artifact( ) }, ).save(manifest_path) - monkeypatch.setenv("GITHUB_TOKEN", "runtime-secret") + 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) @@ -1082,7 +1088,7 @@ def test_wiki_rejects_embedding_route_that_disagrees_with_artifact( str(tmp_path), "--no-index", "--embedding-provider", - "github_models", + "openai", ] ) diff --git a/test/test_cli_remote_embeddings.py b/test/test_cli_remote_embeddings.py index 6249b698..1e639fa9 100644 --- a/test/test_cli_remote_embeddings.py +++ b/test/test_cli_remote_embeddings.py @@ -11,7 +11,7 @@ import pytest -from codenib import cli, provider_routes +from codenib import cli class _EmbeddingHandler(BaseHTTPRequestHandler): @@ -54,7 +54,7 @@ def log_message(self, _format: str, *_args) -> None: return -def test_github_models_semantic_build_uses_remote_sdk_without_sentence_transformers( +def test_openai_semantic_build_uses_remote_sdk_without_sentence_transformers( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -64,25 +64,23 @@ def test_github_models_semantic_build_uses_remote_sdk_without_sentence_transform "def answer(value: int) -> int:\n return value + 1\n" ) monkeypatch.setenv("CODENIB_HOME", str(tmp_path / "home")) - monkeypatch.setenv("GITHUB_TOKEN", "runtime-token") + 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" - monkeypatch.setattr(provider_routes, "GITHUB_MODELS_BASE_URL", endpoint) - try: manifest, failed = cli.index_repository( repo, languages=["python"], views=["vector"], - embedding_provider="github_models", - embedding_model="openai/text-embedding-3-small", + embedding_provider="openai", + embedding_model="text-embedding-3-small", embedding_dimension=4, embedding_endpoint=endpoint, - embedding_credential_env="GITHUB_TOKEN", + embedding_credential_env="EMBEDDING_API_KEY", ) finally: server.shutdown() @@ -91,12 +89,12 @@ def test_github_models_semantic_build_uses_remote_sdk_without_sentence_transform assert failed == [] entry = manifest.indexes["vector"] - assert entry.config["embedding_provider"] == "github_models" + 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 "GITHUB_TOKEN" not in serialized + assert "EMBEDDING_API_KEY" not in serialized assert _EmbeddingHandler.requests assert all( request["path"] == "/inference/embeddings"