From 2329bf3488fa68827d30ed7a4c9f0d43498f5131 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 13 May 2026 12:49:32 -0700 Subject: [PATCH] feat: support non-Gemini LLM providers via LiteLLM model prefixes (#7) DSPy.LM is a thin wrapper around LiteLLM, which already routes by provider prefix (gemini/, openai/, anthropic/, ollama_chat/, groq/, etc.) and reads the matching provider env var automatically. The previous code force-prepended gemini/ to any user-supplied model string, blocking every non-Gemini provider. Stop force-prepending. Use a small _normalize_model_name() helper that only adds the gemini/ prefix when the user passed a bare gemini-* model name, so existing GEMINI_API_KEY users see no behavior change. - cli/virtual_runner.py: prefix logic moved into _normalize_model_name - cr/config.py: doc-comment that LiteLLM picks up provider env vars - cli/main.py: --model help shows examples for openai/anthropic/ollama - README.md / INSTALLATION.md: short "Using non-Gemini providers" section - npx/src/api-key.ts: detect any of GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY; skip the prompt entirely for ollama_chat/ models - npx/python/ parallel tree: mirror of the root-tree changes - tests/test_integration.py: skip-when-no-LLM-env-var (not just Gemini) - npx/python/tests/test_e2e_virtual_runner.py: provider-neutral fixture Closes #7. Same change addresses #10 (local LLMs) and #2 (other models) because the underlying fix is the prefix mechanism, not provider-specific. Backward compat: bare "gemini-3-pro-preview" still works because of the gemini-* heuristic. Users who pass a fully-qualified LiteLLM string (openai/gpt-4o, anthropic/claude-3-5-sonnet, ollama_chat/qwen3:4b) now reach the right provider. --- INSTALLATION.md | 17 +++++++ README.md | 21 +++++++- cli/main.py | 5 +- cli/virtual_runner.py | 17 ++++--- cr/config.py | 4 +- npx/python/cli/main.py | 5 +- npx/python/cli/virtual_runner.py | 18 ++++--- npx/python/cr/config.py | 4 +- npx/python/tests/test_e2e_virtual_runner.py | 56 +++++++++++---------- npx/src/api-key.ts | 25 ++++++--- npx/src/cli.ts | 2 +- npx/src/index.ts | 5 +- npx/src/python-runner.ts | 4 +- tests/test_integration.py | 11 ++-- 14 files changed, 130 insertions(+), 64 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 607fe1b..5e28116 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -103,6 +103,23 @@ GITHUB_TOKEN=your_github_token_here # Optional for npx, required for web UI - Select `repo` scope for private repositories - Select `public_repo` for public repositories only +### Using non-Gemini providers + +AsyncReview uses DSPy/LiteLLM model prefixes, so provider-specific environment +variables are picked up automatically when you choose a matching model. + +```bash +# OpenAI +export OPENAI_API_KEY=your_openai_api_key +asyncreview review --url https://github.com/org/repo/pull/123 \ + -q "Review this" --model openai/gpt-4o-mini + +# Local Ollama +ollama serve +asyncreview review --url https://github.com/org/repo/pull/123 \ + -q "Review this" --model ollama_chat/qwen3:4b +``` + ## Running AsyncReview Locally ### Option 1: Using the API Server + Web UI diff --git a/README.md b/README.md index e34a981..9c150d9 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,30 @@ npx skills add AsyncFuncAI/AsyncReview ### Public Repositories -For public repos, you only need a Gemini API key. +For public repos, you only need a Gemini API key by default. ```bash export GEMINI_API_KEY="your-key" npx asyncreview review --url https://github.com/org/repo/pull/123 -q "Review this" ``` +### Using non-Gemini providers + +AsyncReview passes model names directly to DSPy/LiteLLM, so you can use any +LiteLLM-compatible provider prefix. Gemini remains the default. + +```bash +# OpenAI +export OPENAI_API_KEY="your-key" +npx asyncreview review --url https://github.com/org/repo/pull/123 \ + -q "Review this" --model openai/gpt-4o-mini + +# Local Ollama +ollama serve +npx asyncreview review --url https://github.com/org/repo/pull/123 \ + -q "Review this" --model ollama_chat/qwen3:4b +``` + ### Private Repositories For private repos, you also need a GitHub token. @@ -92,7 +109,7 @@ For private repos, you also need a GitHub token. ## Configuration **Required:** -- **Gemini API Key:** Get one from Google AI Studio. Set as `GEMINI_API_KEY`. +- **LLM API Key:** Gemini uses `GEMINI_API_KEY`; other LiteLLM providers use their own env vars, such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. **Optional:** - **GitHub Token:** Required for private repositories to access file contents. Set as `GITHUB_TOKEN`. diff --git a/cli/main.py b/cli/main.py index 930cd3b..9df0311 100644 --- a/cli/main.py +++ b/cli/main.py @@ -160,7 +160,10 @@ def main(): "--model", "-m", type=str, default=None, - help="Model to use (e.g. gemini-3.0-pro-preview)", + help=( + "Model to use (e.g. gemini/gemini-3-pro-preview, " + "openai/gpt-4o, anthropic/claude-3-5-sonnet, ollama_chat/qwen3:4b)" + ), ) args = parser.parse_args() diff --git a/cli/virtual_runner.py b/cli/virtual_runner.py index b9ad29e..13aa108 100644 --- a/cli/virtual_runner.py +++ b/cli/virtual_runner.py @@ -16,6 +16,13 @@ ) +def _normalize_model_name(model_name: str) -> str: + """Keep legacy bare Gemini names working while allowing LiteLLM prefixes.""" + if model_name.startswith("gemini-"): + return f"gemini/{model_name}" + return model_name + + class VirtualReviewRunner: """Run RLM code reviews on GitHub PRs without a local repository. @@ -31,7 +38,7 @@ def __init__( """Initialize the virtual runner. Args: - model: Override model (e.g. "gemini-3.0-pro-preview") + model: Override model (e.g. "gemini/gemini-3-pro-preview" or "openai/gpt-4o") quiet: If True, suppress progress output on_step: Optional callback for RLM step updates """ @@ -59,11 +66,7 @@ def _ensure_configured(self): logging.getLogger(name).setLevel(logging.WARNING) # Configure DSPy with specified model - model_name = self.model - if not model_name.startswith("gemini/"): - model_name = f"gemini/{model_name}" - - dspy.configure(lm=dspy.LM(model_name)) + dspy.configure(lm=dspy.LM(_normalize_model_name(self.model))) # Create RLM with custom interpreter that has Deno 2.x fix from dspy.primitives.python_interpreter import PythonInterpreter @@ -76,7 +79,7 @@ def _ensure_configured(self): signature="context, question -> answer, sources", max_iterations=MAX_ITERATIONS, max_llm_calls=MAX_LLM_CALLS, - sub_lm=dspy.LM(f"gemini/{SUB_MODEL}" if not SUB_MODEL.startswith("gemini/") else SUB_MODEL), + sub_lm=dspy.LM(_normalize_model_name(SUB_MODEL)), verbose=not self.quiet, interpreter=interpreter, ) diff --git a/cr/config.py b/cr/config.py index b9e8c1e..bb8e52c 100644 --- a/cr/config.py +++ b/cr/config.py @@ -7,6 +7,9 @@ load_dotenv() # LLM Configuration +# LiteLLM reads provider-specific keys such as OPENAI_API_KEY, ANTHROPIC_API_KEY, +# GROQ_API_KEY, and OLLAMA_API_BASE directly based on the model prefix. +# GEMINI_API_KEY remains supported for the default Gemini models. GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") MAIN_MODEL = os.getenv("MAIN_MODEL", "gemini/gemini-3-pro-preview") SUB_MODEL = os.getenv("SUB_MODEL", "gemini/gemini-3-flash-preview") @@ -137,4 +140,3 @@ "test/**", "spec/**", ] - diff --git a/npx/python/cli/main.py b/npx/python/cli/main.py index 9f8207c..566f7bd 100644 --- a/npx/python/cli/main.py +++ b/npx/python/cli/main.py @@ -292,7 +292,10 @@ def main(): "--model", "-m", type=str, default=None, - help="Model to use (e.g. gemini-3.0-pro-preview)", + help=( + "Model to use (e.g. gemini/gemini-3-pro-preview, " + "openai/gpt-4o, anthropic/claude-3-5-sonnet, ollama_chat/qwen3:4b)" + ), ) review_parser.add_argument( "--submit", diff --git a/npx/python/cli/virtual_runner.py b/npx/python/cli/virtual_runner.py index 89d51e4..ba59cf1 100644 --- a/npx/python/cli/virtual_runner.py +++ b/npx/python/cli/virtual_runner.py @@ -25,6 +25,13 @@ +def _normalize_model_name(model_name: str) -> str: + """Keep legacy bare Gemini names working while allowing LiteLLM prefixes.""" + if model_name.startswith("gemini-"): + return f"gemini/{model_name}" + return model_name + + class VirtualReviewRunner: """Run RLM code reviews on GitHub PRs and local directories. @@ -41,7 +48,7 @@ def __init__( """Initialize the virtual runner. Args: - model: Override model (e.g. "gemini-3.0-pro-preview") + model: Override model (e.g. "gemini/gemini-3-pro-preview" or "openai/gpt-4o") quiet: If True, suppress progress output on_step: Optional callback for RLM step updates """ @@ -199,23 +206,18 @@ def _ensure_configured(self): logging.getLogger(name).setLevel(logging.WARNING) # Configure DSPy with specified model (cache=False to prevent disk caching) - model_name = self.model - if not model_name.startswith("gemini/"): - model_name = f"gemini/{model_name}" - - self._lm = dspy.LM(model_name, cache=False) + self._lm = dspy.LM(_normalize_model_name(self.model), cache=False) # Create RLM with custom interpreter that has Deno 2.x fix deno_command = build_deno_command() interpreter = PythonInterpreter(deno_command=deno_command) # Standard signature - sub_model = f"gemini/{SUB_MODEL}" if not SUB_MODEL.startswith("gemini/") else SUB_MODEL self._rlm = dspy.RLM( signature="context, question -> answer, sources", max_iterations=MAX_ITERATIONS, max_llm_calls=MAX_LLM_CALLS, - sub_lm=dspy.LM(sub_model, cache=False), + sub_lm=dspy.LM(_normalize_model_name(SUB_MODEL), cache=False), verbose=not self.quiet, interpreter=interpreter, tools=self._create_tool_functions(), diff --git a/npx/python/cr/config.py b/npx/python/cr/config.py index b9e8c1e..bb8e52c 100644 --- a/npx/python/cr/config.py +++ b/npx/python/cr/config.py @@ -7,6 +7,9 @@ load_dotenv() # LLM Configuration +# LiteLLM reads provider-specific keys such as OPENAI_API_KEY, ANTHROPIC_API_KEY, +# GROQ_API_KEY, and OLLAMA_API_BASE directly based on the model prefix. +# GEMINI_API_KEY remains supported for the default Gemini models. GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") MAIN_MODEL = os.getenv("MAIN_MODEL", "gemini/gemini-3-pro-preview") SUB_MODEL = os.getenv("SUB_MODEL", "gemini/gemini-3-flash-preview") @@ -137,4 +140,3 @@ "test/**", "spec/**", ] - diff --git a/npx/python/tests/test_e2e_virtual_runner.py b/npx/python/tests/test_e2e_virtual_runner.py index aafaabd..6903c0b 100644 --- a/npx/python/tests/test_e2e_virtual_runner.py +++ b/npx/python/tests/test_e2e_virtual_runner.py @@ -1,4 +1,4 @@ -"""E2E tests for VirtualReviewRunner with real Gemini API and GitHub. +"""E2E tests for VirtualReviewRunner with real LLM API and GitHub. These tests verify: 1. FETCH_FILE tool interception works across iterations @@ -7,8 +7,8 @@ 4. Multi-turn RLM conversations handle state correctly Requirements: -- GEMINI_API_KEY environment variable must be set -- Internet connection for GitHub and Gemini API +- GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY environment variable must be set +- Internet connection for GitHub and the selected LLM provider - Deno must be installed and in PATH """ @@ -17,19 +17,23 @@ import pytest from cli.virtual_runner import VirtualReviewRunner +LLM_API_KEY_VARS = ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY") +TEST_MODEL = os.getenv("ASYNCREVIEW_TEST_MODEL", "gemini-2.0-flash-exp") + # Require explicit API key for E2E tests @pytest.fixture(scope="module") -def gemini_api_key(): - """Ensure GEMINI_API_KEY is set for E2E tests.""" - key = os.getenv("GEMINI_API_KEY") - if not key: - pytest.skip("GEMINI_API_KEY not set, skipping E2E tests") - return key +def llm_api_key(): + """Ensure an LLM provider API key is set for E2E tests.""" + for name in LLM_API_KEY_VARS: + key = os.getenv(name) + if key: + return key + pytest.skip("No LLM API key set, skipping E2E tests") @pytest.mark.asyncio -async def test_fetch_file_interception(gemini_api_key): +async def test_fetch_file_interception(llm_api_key): """Test that FETCH_FILE reliably populates repo_files across iterations. This is the core test for the variable rebuild fix. It verifies that when @@ -40,7 +44,7 @@ async def test_fetch_file_interception(gemini_api_key): url = "https://github.com/stanfordnlp/dspy/pull/9240" question = "What is in dspy/predict/rlm.py? Please fetch and analyze the complete contents of this file." - runner = VirtualReviewRunner(model="gemini-3-flash-preview", quiet=False) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=False) # Intercept to verify state propagation original_acall = None @@ -81,12 +85,12 @@ async def intercepted_acall(*args, **kwargs): @pytest.mark.asyncio -async def test_search_code_tool(gemini_api_key): +async def test_search_code_tool(llm_api_key): """Test SEARCH_CODE tool integration.""" url = "https://github.com/stanfordnlp/dspy/pull/9240" question = "Use SEARCH_CODE to find all files related to 'DataFrame'. List the paths you find." - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) answer, sources, metadata = await runner.review(url, question) @@ -97,12 +101,12 @@ async def test_search_code_tool(gemini_api_key): @pytest.mark.asyncio -async def test_list_directory_tool(gemini_api_key): +async def test_list_directory_tool(llm_api_key): """Test LIST_DIR tool integration.""" url = "https://github.com/stanfordnlp/dspy/pull/9240" question = "Use LIST_DIR to list the contents of the 'dspy/predict/' directory." - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) answer, sources, metadata = await runner.review(url, question) @@ -113,13 +117,13 @@ async def test_list_directory_tool(gemini_api_key): @pytest.mark.asyncio -async def test_multi_file_fetch(gemini_api_key): +async def test_multi_file_fetch(llm_api_key): """Test fetching multiple files in sequence.""" url = "https://github.com/stanfordnlp/dspy/pull/9240" question = ("Find and fetch both dspy/predict/rlm.py and any test file related to RLM. " "Compare their contents briefly.") - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) answer, sources, metadata = await runner.review(url, question) @@ -132,12 +136,12 @@ async def test_multi_file_fetch(gemini_api_key): @pytest.mark.asyncio -async def test_error_handling_invalid_path(gemini_api_key): +async def test_error_handling_invalid_path(llm_api_key): """Test that invalid file paths are handled gracefully.""" url = "https://github.com/stanfordnlp/dspy/pull/9240" question = "Try to fetch the file 'nonexistent/fake/path.py' and report what happens." - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) # Should not raise, even with invalid path answer, sources, metadata = await runner.review(url, question) @@ -150,13 +154,13 @@ async def test_error_handling_invalid_path(gemini_api_key): @pytest.mark.asyncio -async def test_issue_review(gemini_api_key): +async def test_issue_review(llm_api_key): """Test reviewing a GitHub issue (not just PRs).""" # Use a known issue url = "https://github.com/stanfordnlp/dspy/issues/100" question = "Summarize what this issue is about." - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) answer, sources, metadata = await runner.review(url, question) @@ -164,14 +168,14 @@ async def test_issue_review(gemini_api_key): assert metadata.get("type") == "issue", "Should identify as issue type" -@pytest.mark.asyncio -async def test_context_preservation(gemini_api_key): +@pytest.mark.asyncio +async def test_context_preservation(llm_api_key): """Test that PR context (diff, description) is preserved alongside tool results.""" url = "https://github.com/stanfordnlp/dspy/pull/9240" question = ("Based on the PR description and the actual code in dspy/predict/rlm.py, " "explain how the DataFrame feature is implemented.") - runner = VirtualReviewRunner(model="gemini-2.0-flash-exp", quiet=True) + runner = VirtualReviewRunner(model=TEST_MODEL, quiet=True) answer, sources, metadata = await runner.review(url, question) @@ -187,9 +191,9 @@ async def test_context_preservation(gemini_api_key): # Allow running tests directly with: python test_e2e_virtual_runner.py import sys - api_key = os.getenv("GEMINI_API_KEY") + api_key = next((os.getenv(name) for name in LLM_API_KEY_VARS if os.getenv(name)), None) if not api_key: - print("ERROR: GEMINI_API_KEY not set") + print("ERROR: no LLM API key set") sys.exit(1) print("Running E2E tests...") diff --git a/npx/src/api-key.ts b/npx/src/api-key.ts index e6c02e8..a7f764e 100644 --- a/npx/src/api-key.ts +++ b/npx/src/api-key.ts @@ -5,23 +5,32 @@ import inquirer from 'inquirer'; import chalk from 'chalk'; -export async function getApiKey(cliApiKey?: string): Promise { +const LLM_API_KEY_VARS = ['GEMINI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY']; + +function usesLocalProvider(model?: string): boolean { + return !!model && (model.startsWith('ollama/') || model.startsWith('ollama_chat/')); +} + +export async function getApiKey(cliApiKey?: string, model?: string): Promise { // 1. Check --api flag first (highest priority) if (cliApiKey) { return cliApiKey; } - // 2. Check environment variable - const envKey = process.env.GEMINI_API_KEY; - if (envKey) { - return envKey; + // 2. Check provider-specific environment variables used by LiteLLM + if (LLM_API_KEY_VARS.some((name) => process.env[name])) { + return undefined; + } + + if (usesLocalProvider(model)) { + return undefined; } // 3. No API key found - prompt user - console.log(chalk.yellow('\n⚠️ No Gemini API key found.\n')); + console.log(chalk.yellow('\n⚠️ No LLM API key found.\n')); console.log(chalk.dim('You can set it via:')); - console.log(chalk.dim(' • --api flag')); - console.log(chalk.dim(' • GEMINI_API_KEY environment variable\n')); + console.log(chalk.dim(' • --api flag for Gemini')); + console.log(chalk.dim(' • GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY environment variable\n')); const answers = await inquirer.prompt([ { diff --git a/npx/src/cli.ts b/npx/src/cli.ts index bc5571b..40d761b 100644 --- a/npx/src/cli.ts +++ b/npx/src/cli.ts @@ -32,7 +32,7 @@ export async function runReview(options: ReviewOptions): Promise { try { // 4. Get API key - const apiKey = await getApiKey(api); + const apiKey = await getApiKey(api, model); // 5. Get GitHub token only if using URL mode (not required for local path mode) let ghToken: string | undefined; diff --git a/npx/src/index.ts b/npx/src/index.ts index c2a47cb..84aa936 100644 --- a/npx/src/index.ts +++ b/npx/src/index.ts @@ -23,7 +23,10 @@ program .option('--expert', 'Run expert code review (SOLID, Security, Performance, Code Quality)') .option('-o, --output ', 'Output format: text, markdown, json', 'text') .option('--quiet', 'Suppress progress output') - .option('-m, --model ', 'Model to use (e.g. gemini-3-pro-preview)') + .option( + '-m, --model ', + 'Model to use (e.g. gemini/gemini-3-pro-preview, openai/gpt-4o, anthropic/claude-3-5-sonnet, ollama_chat/qwen3:4b)' + ) .option('--api ', 'Gemini API key (defaults to GEMINI_API_KEY env var)') .option('--github-token ', 'GitHub token for private repos (defaults to GITHUB_TOKEN env var)') .action(async (options) => { diff --git a/npx/src/python-runner.ts b/npx/src/python-runner.ts index 5bd7295..cb3bb77 100644 --- a/npx/src/python-runner.ts +++ b/npx/src/python-runner.ts @@ -257,7 +257,7 @@ export interface RunOptions { output: string; quiet: boolean; model?: string; - apiKey: string; + apiKey?: string; githubToken?: string; expert?: boolean; } @@ -319,7 +319,7 @@ export async function runPythonReview(options: RunOptions): Promise { cwd: PYTHON_CODE_ROOT, env: { ...process.env, - GEMINI_API_KEY: options.apiKey, + ...(options.apiKey && { GEMINI_API_KEY: options.apiKey }), ...(options.githubToken && { GITHUB_TOKEN: options.githubToken }), PYTHONPATH: pythonPath, // Ensure we don't inherit conflicting python env vars diff --git a/tests/test_integration.py b/tests/test_integration.py index 8f1bbb3..de9858e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,7 +1,7 @@ -"""Integration tests with real Gemini API calls. +"""Integration tests with real LLM API calls. These tests require: -1. GEMINI_API_KEY in .env +1. GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY in .env 2. GITHUB_TOKEN in .env (for public repo access) Run with: uv run pytest tests/test_integration.py -v -s @@ -14,10 +14,12 @@ import os import pytest +LLM_API_KEY_VARS = ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY") + # Skip all tests if no API key pytestmark = pytest.mark.skipif( - not os.getenv("GEMINI_API_KEY") and not os.path.exists(".env"), - reason="GEMINI_API_KEY not set" + not any(os.getenv(name) for name in LLM_API_KEY_VARS) and not os.path.exists(".env"), + reason="No LLM API key set", ) @@ -118,4 +120,3 @@ async def test_full_flow(self, load_env): print(f"[{block['type']}]: {block['content'][:200]}...") assert len(answer["answerBlocks"]) > 0 -