Skip to content

Commit 3017020

Browse files
WilliamBergaminClaude
andauthored
test: pool Gemini API keys to spread eval load across quota (#84)
* test: pool Gemini API keys to spread eval load across quota Collect every env var whose name starts with GEMINI_API_KEY into a pool and pick one at random per eval request, spreading load across the free-tier quota to avoid RESOURCE_EXHAUSTED. Build a fresh judge model per test method so each scenario can draw a different key. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com> * Update env vars * Improve token collection --------- Co-authored-by: Claude <svc-devxp-claude@slack-corp.com>
1 parent c4caf79 commit 3017020

7 files changed

Lines changed: 57 additions & 13 deletions

File tree

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
# Google Gemini API key, required for the LLM-judged eval tests (make test-eval).
2-
# The eval suite fails when this is unset.
1+
# Google Gemini API key(s), required for the LLM-judged eval tests (make test-eval).
2+
# The eval suite fails when no key is set. To spread requests across the quota,
3+
# add more keys under any GEMINI_API_KEY* name.
34
GEMINI_API_KEY=
5+
# GEMINI_API_KEY_BOB=
6+
# GEMINI_API_KEY_MIC=
47

58
# Gemini model used as the DeepEval judge. Defaults to gemini-3.1-flash-lite if unset.
69
GEMINI_MODEL_NAME=gemini-3.1-flash-lite

.github/workflows/ci-build.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ jobs:
9191
- name: Run eval tests
9292
run: make test-eval
9393
env:
94-
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
9594
SLACK_MCP_TOKEN: ${{ secrets.SLACK_MCP_TOKEN }}
95+
GEMINI_API_KEY_B: ${{ secrets.GEMINI_API_KEY_BOB }}
96+
GEMINI_API_KEY_M: ${{ secrets.GEMINI_API_KEY_MWBROOKS }}
97+
GEMINI_API_KEY_E: ${{ secrets.GEMINI_API_KEY_ELAINE }}
98+
GEMINI_API_KEY_Z: ${{ secrets.GEMINI_API_KEY_ZIMEG }}
9699

97100
notifications:
98101
name: Regression notifications

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Requires Python 3.14+. Run `make install` before first use to set up the virtual
2929
| `make cursor-install` | Install this plugin into a local Cursor for development |
3030
| `make cursor-uninstall` | Uninstall this plugin from the local Cursor install |
3131

32-
The LLM tests read `GEMINI_API_KEY` (required — the eval suite fails when it's unset) and `SLACK_MCP_TOKEN` (a Slack MCP bearer token; the MCP tool-selection test is skipped when it's unset). The DeepEval judge model defaults to `gemini-3.1-flash-lite`, overridable via `GEMINI_MODEL_NAME`. Copy `.env.example` to `.env` and fill in values — the test suite loads `.env` from the repo root via `python-dotenv` (`tests/config.py`), so values load the same however tests are launched. Real environment variables take precedence over `.env`, so you can also override inline, e.g. `GEMINI_MODEL_NAME=<model> make test-eval`.
32+
The LLM tests read at least one Gemini API key (required — the eval suite fails when none is set) and `SLACK_MCP_TOKEN` (a Slack MCP bearer token; the MCP tool-selection test is skipped when it's unset). To spread requests across the free-tier quota and avoid `RESOURCE_EXHAUSTED`, any env var whose name starts with `GEMINI_API_KEY` (e.g. `GEMINI_API_KEY`, `GEMINI_API_KEY_BOB`) contributes a key to a pool (blank values are skipped), and each eval request picks one at random. The DeepEval judge model defaults to `gemini-3.1-flash-lite`, overridable via `GEMINI_MODEL_NAME`. Copy `.env.example` to `.env` and fill in values — the test suite loads `.env` from the repo root via `python-dotenv` (`tests/config.py`), so values load the same however tests are launched. Real environment variables take precedence over `.env`, so you can also override inline, e.g. `GEMINI_MODEL_NAME=<model> make test-eval`.
3333

3434
## Cross-Skill References
3535

@@ -60,7 +60,7 @@ GitHub Actions (`.github/workflows/ci-build.yml`) gates every PR with:
6060
- **Test**`make test-unit` (pytest)
6161
- **Eval**`make test-eval` (DeepEval + Gemini)
6262

63-
The eval job reads the `GEMINI_API_KEY` and `SLACK_MCP_TOKEN` repository secrets; it skips on PRs from forks, which don't receive secrets. The workflow also runs nightly on a schedule, and a `notifications` job posts to Slack (via `SLACK_REGRESSION_FAILURES_WEBHOOK_URL`) when a job fails on `main`.
63+
The eval job reads the `GEMINI_API_KEY_*` (e.g. `GEMINI_API_KEY_BOB`, `GEMINI_API_KEY_MIC`) and `SLACK_MCP_TOKEN` repository secrets; it skips on PRs from forks, which don't receive secrets. The workflow also runs nightly on a schedule, and a `notifications` job posts to Slack (via `SLACK_REGRESSION_FAILURES_WEBHOOK_URL`) when a job fails on `main`.
6464

6565
## Releasing
6666

tests/config.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import json
22
import os
3+
from collections.abc import Mapping
34
from pathlib import Path
45

56
from dotenv import load_dotenv
67

78
# Load .env from the repo root (if present) before reading the environment
89
load_dotenv(Path(__file__).parent.parent / ".env")
910

11+
12+
def get_gemini_api_key_pool(environ: Mapping[str, str]) -> list[str]:
13+
return [environ[name].strip() for name in environ if name.startswith("GEMINI_API_KEY") and environ[name].strip()]
14+
15+
1016
# Filesystem
1117
SKILLS_ROOT = Path(__file__).parent.parent / "skills"
1218

@@ -17,8 +23,9 @@
1723
# Skill inventory (single source of truth)
1824
EXPECTED_SKILLS = ("create-slack-app", "block-kit", "slack-api", "slack-cli")
1925

20-
# Gemini judge model
21-
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
26+
# Gemini judge model. Any env var whose name starts with GEMINI_API_KEY contributes a
27+
# key to the pool; blank values are skipped so an empty GEMINI_API_KEY= doesn't sneak in.
28+
GEMINI_API_KEY_POOL = get_gemini_api_key_pool(os.environ)
2229
GEMINI_MODEL = os.environ.get("GEMINI_MODEL_NAME", "gemini-3.1-flash-lite")
2330

2431
# Slack MCP server

tests/eval/test_tool_selection.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from deepeval.test_case import ToolCall
77
from pydantic import BaseModel
88

9-
from tests.config import GEMINI_API_KEY, SLACK_MCP_TOKEN
9+
from tests.config import GEMINI_API_KEY_POOL, SLACK_MCP_TOKEN
1010
from tests.support.judge import make_judge_model
1111
from tests.support.tools import get_all_skill_tools, get_slack_mcp_tools
1212

@@ -189,15 +189,17 @@ class TestToolSelection:
189189

190190
@classmethod
191191
def setup_class(cls) -> None:
192-
if not GEMINI_API_KEY:
193-
pytest.fail("GEMINI_API_KEY not set")
192+
if not GEMINI_API_KEY_POOL:
193+
pytest.fail("No Gemini API key set (set GEMINI_API_KEY or GEMINI_API_KEY_*)")
194194
if not SLACK_MCP_TOKEN:
195195
pytest.fail("SLACK_MCP_TOKEN not set")
196196
# Fetch tools once for the whole class: the MCP list is one network
197197
# round-trip, and skills are read from disk.
198-
cls.model = make_judge_model()
199198
cls.available_tools = get_slack_mcp_tools() + get_all_skill_tools()
200199

200+
def setup_method(self) -> None:
201+
self.model = make_judge_model()
202+
201203
def teardown_method(self) -> None:
202204
# Gemini's free tier allows only 15 requests/minute. Each scenario makes one
203205
# model.generate() call, so sleep between scenarios to stay well under the

tests/support/judge.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import random
2+
13
from deepeval.models import GeminiModel
24

3-
from tests.config import GEMINI_API_KEY, GEMINI_MODEL
5+
from tests.config import GEMINI_API_KEY_POOL, GEMINI_MODEL
46

57

68
def make_judge_model() -> GeminiModel:
79
"""Gemini model used as the DeepEval judge for LLM-graded eval tests."""
8-
return GeminiModel(model=GEMINI_MODEL, api_key=GEMINI_API_KEY, temperature=0)
10+
return GeminiModel(model=GEMINI_MODEL, api_key=random.choice(GEMINI_API_KEY_POOL), temperature=0)

tests/unit/test_config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from tests.config import get_gemini_api_key_pool
2+
3+
4+
class TestGeminiApiKeyPool:
5+
def test_collects_all_prefixed_vars(self) -> None:
6+
environ = {"GEMINI_API_KEY": "primary", "GEMINI_API_KEY_BOB": "bob"}
7+
assert set(get_gemini_api_key_pool(environ)) == {"primary", "bob"}
8+
9+
def test_bare_prefix_included(self) -> None:
10+
assert get_gemini_api_key_pool({"GEMINI_API_KEY": "primary"}) == ["primary"]
11+
12+
def test_blank_and_whitespace_values_skipped(self) -> None:
13+
environ = {"GEMINI_API_KEY": "", "GEMINI_API_KEY_X": " ", "GEMINI_API_KEY_Y": "real"}
14+
assert get_gemini_api_key_pool(environ) == ["real"]
15+
16+
def test_values_are_stripped(self) -> None:
17+
assert get_gemini_api_key_pool({"GEMINI_API_KEY": " abc "}) == ["abc"]
18+
19+
def test_non_matching_vars_ignored(self) -> None:
20+
environ = {"SLACK_MCP_TOKEN": "token", "PATH": "/usr/bin", "GEMINI_MODEL_NAME": "flash"}
21+
assert get_gemini_api_key_pool(environ) == []
22+
23+
def test_prefix_must_be_at_start(self) -> None:
24+
assert get_gemini_api_key_pool({"MY_GEMINI_API_KEY": "nope"}) == []
25+
26+
def test_empty_environment(self) -> None:
27+
assert get_gemini_api_key_pool({}) == []

0 commit comments

Comments
 (0)