diff --git a/.amplifier/digital-twin-universe/profiles/e2e.yaml b/.amplifier/digital-twin-universe/profiles/e2e.yaml index 9e50d395..990c0b8d 100644 --- a/.amplifier/digital-twin-universe/profiles/e2e.yaml +++ b/.amplifier/digital-twin-universe/profiles/e2e.yaml @@ -24,6 +24,13 @@ passthrough: # the whole auth story: no credential files, no device flow, no mounts. # Set it host-side with `export GITHUB_TOKEN=$(gh auth token)` before launching. - { name: github_token, key_env: GITHUB_TOKEN } + # Only the gemini suite needs this. GOOGLE_API_KEY is the canonical variable: the + # Google GenAI SDK also accepts GEMINI_API_KEY, but GOOGLE_API_KEY takes precedence + # and is the sole entry in PROVIDER_CREDENTIAL_VARS, so it is what `providers list` + # and `models list` consult. Unset is safe -- DTU's passthrough writes the export + # under a bare `if value:` guard, so an absent value produces no export and no error, + # and the gemini suite skips itself rather than failing. + - { name: google, key_env: GOOGLE_API_KEY } url_rewrites: # amplifier-agent is git-installed (uv tool install git+...), so we rewrite the diff --git a/CHANGELOG.md b/CHANGELOG.md index 5020fc41..621bd890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Gemini provider.** `provider.module: "gemini"` is now a valid host-config value, + backed by `amplifier-module-provider-gemini`. It talks to Google's Gemini API, with + large context windows and thinking/reasoning support. Unlike `openai-chatgpt` and + `chat-completions` (added in 0.13.0), it is a normal key-based provider like + `anthropic` and `openai`: credentials resolve from `GOOGLE_API_KEY` (the module's own + env read also accepts `GEMINI_API_KEY`, `GOOGLE_API_KEY` taking precedence when both + are set), and `auth set gemini` is accepted and stores the key like any other keyed + provider. Default model is `gemini-2.5-flash`. + ## [0.13.0] — 2026-08-18 ### Added diff --git a/README.md b/README.md index b482b6fd..be3b504a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Public integrations run opencode, paperclip, and NanoClaw on it: see [who has in `amplifier-agent` ships with: -- Seven providers behind one interface: Anthropic, OpenAI, Azure OpenAI, Ollama, GitHub Copilot, ChatGPT (a Plus/Pro/Team subscription via OAuth device-code, no API key), and Chat Completions (any OpenAI Chat Completions-compatible endpoint, e.g. llama.cpp, vLLM, LM Studio), with credentials read from the environment or a cached OAuth session +- Eight providers behind one interface: Anthropic, OpenAI, Azure OpenAI, Ollama, GitHub Copilot, ChatGPT (a Plus/Pro/Team subscription via OAuth device-code, no API key), Chat Completions (any OpenAI Chat Completions-compatible endpoint, e.g. llama.cpp, vLLM, LM Studio), and Gemini (Google's Gemini API, large context windows plus thinking/reasoning support), with credentials read from the environment or a cached OAuth session - Role-based model routing, so a sub-agent gets a model matched to its job rather than the frontier model for everything, re-matched when you switch providers - Context management that keeps long sessions running, compacting history before it overruns the window - Tools for filesystem, bash, web, search, todo, and MCP diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f6468e32..cae3227b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -10,6 +10,7 @@ Provider is auto-detected from environment variables in this precedence: 2. `OPENAI_API_KEY` 3. `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT` 4. `OLLAMA_HOST` (defaults to `http://localhost:11434`) +5. `GOOGLE_API_KEY` (`GEMINI_API_KEY` is also accepted by the provider module, `GOOGLE_API_KEY` takes precedence) `github-copilot`, `openai-chatgpt`, and `chat-completions` are excluded from this auto-detect chain -- none of them resolves from a single API-key environment variable. `github-copilot` reads its own @@ -52,6 +53,7 @@ For "set once, works everywhere" instead of editing shell rc files, the `auth` s amplifier-agent auth set anthropic sk-ant-... amplifier-agent auth set openai sk-... amplifier-agent auth set azure-openai sk-... --endpoint https://... +amplifier-agent auth set gemini AIza... amplifier-agent auth list # configured providers, api keys masked amplifier-agent auth status # diagnose env-vs-file precedence per provider amplifier-agent auth remove openai # delete a single entry diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index 8f9ebf46..e741253d 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -67,9 +67,16 @@ required by any test that runs a real model and by the HTTP server startup. `GITHUB_TOKEN` must be set for the `github_copilot` suite (only that suite; everything else runs without it). Set it with `export GITHUB_TOKEN=$(gh auth token)` and re-provision. The value is snapshotted into the container at launch, so exporting it after a DTU is already -running has no effect. `dtu_manager.provision()` warns when either variable is missing, -because DTU's passthrough silently skips an unset value and the failure would otherwise -surface much later as an opaque provider auth error. +running has no effect. `dtu_manager.provision()` warns when any of these variables is +missing, because DTU's passthrough silently skips an unset value and the failure would +otherwise surface much later as an opaque provider auth error. + +`GOOGLE_API_KEY` is optional and only the `gemini` suite uses it. Without it that suite +skips itself rather than failing, so a full run stays green for anyone who has no Google +credential. Set it to run the suite, and re-provision afterwards for the same +snapshot-at-launch reason. `GOOGLE_API_KEY` is the canonical variable even though the +Google GenAI SDK also accepts `GEMINI_API_KEY`: it takes precedence, and it is the one +`providers list` and `models list` consult. ## Running diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index c9ec3ddc..655bd920 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -8,7 +8,7 @@ The engine runs **one turn per invocation** and exits. Continuity across turns c `amplifier-agent` is a standalone binary. You do not need the Amplifier CLI, bundles, or any other repository in the `microsoft/amplifier*` family, and none of them is a substitute for it here. -Use it when your software needs to run an agent: a loop with tools, file access, sub-agents, and/or multi-turn state. It also works for plain LLM calls, where you get routing across seven providers behind one interface. +Use it when your software needs to run an agent: a loop with tools, file access, sub-agents, and/or multi-turn state. It also works for plain LLM calls, where you get routing across eight providers behind one interface. Then pick a surface below, install the engine ([INSTALL.md](INSTALL.md)), and finish with the [checklist](#checklist-for-a-new-integration). diff --git a/docs/LAYERS_AND_RELEASES.md b/docs/LAYERS_AND_RELEASES.md index 1e39ed2f..1aa51841 100644 --- a/docs/LAYERS_AND_RELEASES.md +++ b/docs/LAYERS_AND_RELEASES.md @@ -115,7 +115,7 @@ The engine ships with `bundle.md` baked into the wheel. It declares which module **Pre-wired modules:** -- **Providers:** `provider-anthropic`, `provider-openai`, `provider-azure-openai`, `provider-ollama`, `provider-github-copilot`, `provider-openai-chatgpt`, `provider-chat-completions` +- **Providers:** `provider-anthropic`, `provider-openai`, `provider-azure-openai`, `provider-ollama`, `provider-github-copilot`, `provider-openai-chatgpt`, `provider-chat-completions`, `provider-gemini` - **Orchestrator:** `loop-streaming` (with `extended_thinking: true`) - **Context:** `context-simple` (300K tokens, auto-compact at 80%) - **Tools:** `tool-filesystem`, `tool-bash`, `tool-web`, `tool-search`, `tool-todo`, `tool-apply-patch`, `tool-delegate`, `tool-mcp`, `tool-skills`, `tool-mode`, `tool-recipes` diff --git a/docs/architecture/architecture.dot b/docs/architecture/architecture.dot index 9375d3b3..30a2de4a 100644 --- a/docs/architecture/architecture.dot +++ b/docs/architecture/architecture.dot @@ -84,7 +84,7 @@ digraph amplifier_agent { node [fillcolor="#d9d9d9"]; kernel [label="amplifier-foundation\nbundle / session kernel"]; - providers [label="LLM providers\nanthropic · openai · azure\nollama · copilot · chatgpt\nchat-completions"]; + providers [label="LLM providers\nanthropic · openai · azure\nollama · copilot · chatgpt\nchat-completions · gemini"]; mcp [label="MCP servers"]; } diff --git a/docs/spec/bundle-and-cache.md b/docs/spec/bundle-and-cache.md index 53787a53..073dc647 100644 --- a/docs/spec/bundle-and-cache.md +++ b/docs/spec/bundle-and-cache.md @@ -31,7 +31,7 @@ default_provider: anthropic REQUIRED, engine-level, top-level key providers: install-only stubs, no config and no credentials provider-anthropic, provider-openai, provider-azure-openai, provider-ollama, provider-github-copilot, provider-openai-chatgpt, - provider-chat-completions + provider-chat-completions, provider-gemini session.orchestrator: loop-streaming extended_thinking: true session.context: context-simple max_tokens 300000, auto_compact diff --git a/docs/spec/host-config.md b/docs/spec/host-config.md index 89d2940b..d681ae87 100644 --- a/docs/spec/host-config.md +++ b/docs/spec/host-config.md @@ -82,7 +82,7 @@ approval.patterns must be a list of strings ``` provider.module one of: anthropic, openai, azure-openai, ollama, github-copilot, - openai-chatgpt, chat-completions + openai-chatgpt, chat-completions, gemini provider.config free-form; belongs to the provider module ``` @@ -102,7 +102,7 @@ module config. Closed per-entry schema: } ``` -`module` defaults to the entry's own id when omitted and must be one of the six valid module names. +`module` defaults to the entry's own id when omitted and must be one of the eight valid module names. `config` must be an object. Unknown keys inside an entry raise `config_unknown_key`. An empty `providers` object passes validation; HTTP startup rejects it separately at boot so single-turn mode never trips on a stale block. @@ -216,7 +216,7 @@ config_invalid_type A typed field has the wrong shape: approval.patte debug not a dict, unknown sub-key under debug.*, or debug.rawLlmPayloads not a bool; providers not an object, bad entry shape, or non-dict entry config. -config_invalid_provider_module provider.module outside the 5 valid names, or providers..module +config_invalid_provider_module provider.module outside the 8 valid names, or providers..module outside them. config_no_matching_module host_config declares a non-empty `skills:` block but the bundle has no skills tool module mounted. An empty skills block plus a missing diff --git a/docs/spec/providers-and-models.md b/docs/spec/providers-and-models.md index 11d3cec4..2d8a144b 100644 --- a/docs/spec/providers-and-models.md +++ b/docs/spec/providers-and-models.md @@ -9,7 +9,7 @@ routes a wire `model` field to a provider (see `http-face.md`). ## Supported providers -Seven providers are supported, and only seven. The provider name is the value used in configuration, +Eight providers are supported, and only eight. The provider name is the value used in configuration, in `auth` subcommands, and in `models list --provider`. ``` @@ -20,10 +20,11 @@ ollama provider-ollama github-copilot provider-github-copilot openai-chatgpt provider-openai-chatgpt chat-completions provider-chat-completions +gemini provider-gemini ``` Each module is installed from `git+https://github.com/microsoft/amplifier-module-@main`. -All seven are declared by the shipped bundle (`bundle.md`'s top-level `providers:` stub list) as +All eight are declared by the shipped bundle (`bundle.md`'s top-level `providers:` stub list) as install-only, so preparing the bundle makes every provider importable before any session exists. The agent holds no static table of default models, credential field shapes, or display names. Those @@ -52,6 +53,7 @@ ollama OLLAMA_HOST, then OLLAMA_BASE_URL github-copilot GITHUB_TOKEN openai-chatgpt (none -- OAuth device-code) chat-completions CHAT_COMPLETIONS_BASE_URL, plus optional CHAT_COMPLETIONS_API_KEY +gemini GOOGLE_API_KEY ``` `AZURE_OPENAI_KEY` is the only deprecated alias. Consulting it emits a one-time warning on stderr. @@ -80,6 +82,13 @@ chat-completions -- with no `CHAT_COMPLETIONS_BASE_URL` in the environment it re unconditionally to `source == "none"`, with no file fallback and no usable default to fall back to (unlike ollama's built-in localhost). +gemini lists only `GOOGLE_API_KEY` here. The Google GenAI SDK also accepts `GEMINI_API_KEY` +(`GOOGLE_API_KEY` takes precedence when both are set), and the provider module's own env read +honours that; listing `GEMINI_API_KEY` in this table would mark it deprecated, which it is not. +Otherwise gemini follows the generic env-then-file chain like anthropic and openai: it is a normal +key-based provider, `auth set gemini` is accepted, and it is not excluded from the credential model +the way github-copilot, openai-chatgpt, and chat-completions are. + A resolution reports the provider, whether it resolved, the source (`env`, `file`, `default`, or `none`), the variable consulted, and the resolved fields. Ollama backed only by the built-in default host reports unresolved on purpose, so auto-enrollment does not enlist a local daemon that may not @@ -144,7 +153,7 @@ those instead of relying on `auth set` for this provider. 3. no further fallback: a bundle declaring neither is a hard error at boot ``` -`provider.module` is closed to the seven supported names. Any other value fails validation with +`provider.module` is closed to the eight supported names. Any other value fails validation with error code `config_invalid_provider_module`. `"auto"` is not a valid value. There is no `--provider` flag and no environment-based provider auto-detection. See Non-goals. diff --git a/skills/amplifier-agent/SKILL.md b/skills/amplifier-agent/SKILL.md index 719d7bb3..d46615b1 100644 --- a/skills/amplifier-agent/SKILL.md +++ b/skills/amplifier-agent/SKILL.md @@ -22,7 +22,7 @@ metadata: `amplifier-agent` is an agent engine that other software runs on. Give it a prompt and it runs the full loop, with tools, sub-agents, skills, and MCP, then returns a result. Anything that can spawn a subprocess can use it; Python hosts can embed the engine library in-process instead. -Reach for it when the project needs an *agent* (a tool loop, file access, sub-agents, multi-turn state) rather than a single completion. You can also use it for plain LLM calls, with routing across seven providers behind one interface. +Reach for it when the project needs an *agent* (a tool loop, file access, sub-agents, multi-turn state) rather than a single completion. You can also use it for plain LLM calls, with routing across eight providers behind one interface. **The engine runs one turn per invocation and exits.** Continuity across turns comes from a session id, not from a long-lived process. Every surface below is a different way of delivering a prompt to that same engine. @@ -191,7 +191,7 @@ A per-instance config file looks like this: | `config_unreadable`, `config_malformed_json` | The `--config` file could not be opened, or is not a JSON object | Check the path the host wrote, and that it serialized an object | | `config_unknown_key` | Unrecognized **top-level** config key | The top level is closed: `approval`, `provider`, `providers`, `mcp`, `skills`, `debug`, `allowProtocolSkew` | | `config_invalid_type` | A known key has the wrong type, or an unknown sub-key in a closed inner shape | `skills.*` and `debug.*` are closed and raise this rather than `config_unknown_key`, which is reserved for the top level and `providers.` entries | -| `config_invalid_provider_module` | `provider.module` is not a known provider | One of `anthropic`, `openai`, `azure-openai`, `ollama`, `github-copilot`, `openai-chatgpt`, `chat-completions`. `"auto"` is not valid | +| `config_invalid_provider_module` | `provider.module` is not a known provider | One of `anthropic`, `openai`, `azure-openai`, `ollama`, `github-copilot`, `openai-chatgpt`, `chat-completions`, `gemini`. `"auto"` is not valid | | `protocol_version_mismatch` | Wrapper and engine protocol versions differ | Update the lagging side. `allowProtocolSkew` is an unblock, not a fix | | `lifecycle_unsupported` | `submit()` called twice on one handle | New handle per turn, same `sessionId` with `resume` | | `env_injection_rejected` | The wrapper refused the environment you asked it to inject | Check the key against the wrapper's allowlist and blocked-key list | diff --git a/src/amplifier_agent_cli/provider_sources.py b/src/amplifier_agent_cli/provider_sources.py index 64553b72..0bd8b092 100644 --- a/src/amplifier_agent_cli/provider_sources.py +++ b/src/amplifier_agent_cli/provider_sources.py @@ -113,6 +113,7 @@ def _emit_legacy_env_var_notice(legacy_var: str, preferred_var: str) -> None: "github-copilot", "openai-chatgpt", "chat-completions", + "gemini", ) @@ -152,6 +153,10 @@ def _emit_legacy_env_var_notice(legacy_var: str, preferred_var: str) -> None: "module": "provider-chat-completions", "source": "git+https://github.com/microsoft/amplifier-module-provider-chat-completions@main", }, + "gemini": { + "module": "provider-gemini", + "source": "git+https://github.com/microsoft/amplifier-module-provider-gemini@main", + }, } @@ -185,6 +190,13 @@ def _emit_legacy_env_var_notice(legacy_var: str, preferred_var: str) -> None: # listing them would produce a spurious deprecation warning. amplifier-agent only # needs one var to answer "is this provider configured". "github-copilot": ("GITHUB_TOKEN",), + # Google GenAI SDK accepts BOTH GOOGLE_API_KEY and GEMINI_API_KEY as + # first-class (GOOGLE_API_KEY takes precedence). Only the primary is listed + # here: entries past index 0 are treated as deprecated aliases and emit a + # spurious stderr deprecation notice, which GEMINI_API_KEY is not. A + # GEMINI_API_KEY-only user is still served by the module's own env read at + # mount; amplifier-agent only needs one var to answer "is this configured". + "gemini": ("GOOGLE_API_KEY",), } #: Ollama's own env var chain includes a second, non-legacy alias diff --git a/src/amplifier_agent_lib/bundle/bundle.md b/src/amplifier_agent_lib/bundle/bundle.md index cca2655a..041408e5 100644 --- a/src/amplifier_agent_lib/bundle/bundle.md +++ b/src/amplifier_agent_lib/bundle/bundle.md @@ -72,6 +72,8 @@ providers: source: git+https://github.com/microsoft/amplifier-module-provider-openai-chatgpt@main - module: provider-chat-completions source: git+https://github.com/microsoft/amplifier-module-provider-chat-completions@main + - module: provider-gemini + source: git+https://github.com/microsoft/amplifier-module-provider-gemini@main session: raw: true diff --git a/src/amplifier_agent_lib/config/loader.py b/src/amplifier_agent_lib/config/loader.py index 79bd477b..3d433a64 100644 --- a/src/amplifier_agent_lib/config/loader.py +++ b/src/amplifier_agent_lib/config/loader.py @@ -30,7 +30,7 @@ _VALID_TOP_LEVEL_KEYS = frozenset({"mcp", "approval", "provider", "providers", "allowProtocolSkew", "skills", "debug"}) _VALID_PROVIDER_MODULES = frozenset( - {"anthropic", "openai", "azure-openai", "ollama", "github-copilot", "openai-chatgpt", "chat-completions"} + {"anthropic", "openai", "azure-openai", "ollama", "github-copilot", "openai-chatgpt", "chat-completions", "gemini"} ) # G3: explicit set of host-supplied approval modes. ``CliApprovalSystem`` accepts # exactly these three strings; any other value must be rejected at parse time diff --git a/tests/e2e/framework/dtu_manager.py b/tests/e2e/framework/dtu_manager.py index 3c406725..9f5f08c9 100644 --- a/tests/e2e/framework/dtu_manager.py +++ b/tests/e2e/framework/dtu_manager.py @@ -91,18 +91,20 @@ def _check_passthrough_env() -> None: Deliberately a warning, not a hard failure: these are per-suite requirements, and a missing GITHUB_TOKEN should not block someone running the skills or modes suites. - The github_copilot suite enforces its own requirement directly (and inside the - container, which is what actually matters) via ``test_ghcp_token_reaches_dtu``. + Each suite enforces its own requirement directly, and inside the container, which + is what actually matters -- github_copilot fails loud via + ``test_ghcp_token_reaches_dtu``, gemini skips itself via its ``gemini_key`` fixture. """ required = ( - ("ANTHROPIC_API_KEY", "most suites"), - ("GITHUB_TOKEN", "the github_copilot suite"), + ("ANTHROPIC_API_KEY", "most suites will fail"), + ("GITHUB_TOKEN", "the github_copilot suite will fail"), + ("GOOGLE_API_KEY", "the gemini suite will skip"), ) - for var, suite in required: + for var, consequence in required: if not os.environ.get(var): print( f"[dtu_manager] warning: {var} is not set on this process, so it will NOT " - f"be exported inside the DTU; {suite} will fail." + f"be exported inside the DTU; {consequence}." ) diff --git a/tests/e2e/suites/gemini/__init__.py b/tests/e2e/suites/gemini/__init__.py new file mode 100644 index 00000000..4cf1f9e3 --- /dev/null +++ b/tests/e2e/suites/gemini/__init__.py @@ -0,0 +1,3 @@ +"""``gemini`` provider e2e suite.""" + +from __future__ import annotations diff --git a/tests/e2e/suites/gemini/cases.py b/tests/e2e/suites/gemini/cases.py new file mode 100644 index 00000000..b70d6357 --- /dev/null +++ b/tests/e2e/suites/gemini/cases.py @@ -0,0 +1,167 @@ +"""Case definitions for the ``gemini`` provider suite. + +Scope is deliberately narrow. PR #129 registered gemini as the eighth provider: +a catalog entry, a credential variable, an accepted ``provider.module`` value, +and an install-only bundle stub. Nothing about the provider's own behaviour is +ours to test -- that belongs to ``amplifier-module-provider-gemini``. So this +suite proves the four things amplifier-agent itself is now responsible for: + + 1. gemini appears in the credential-resolution report with the right module + and the right env var + 2. its models can be listed + 3. a host config naming it passes validation + 4. a real session actually runs on it + +Assertions are on the public payloads documented in +``docs/spec/providers-and-models.md`` and ``docs/spec/cli.md``, never on log +output or internals. +""" + +from __future__ import annotations + +from typing import Any + +from framework.assertions import expect_contains +from framework.harness import E2ECase + +#: Provider name as it appears in configuration, ``auth`` subcommands, and +#: ``models list --provider``. See docs/spec/providers-and-models.md. +PROVIDER_ID = "gemini" + +#: Module the catalog maps ``gemini`` onto. +PROVIDER_MODULE = "provider-gemini" + +#: The one credential variable amplifier-agent consults. The Google GenAI SDK +#: also accepts GEMINI_API_KEY, but GOOGLE_API_KEY takes precedence and is the +#: sole entry in PROVIDER_CREDENTIAL_VARS, so it is the canonical one here too. +CREDENTIAL_VAR = "GOOGLE_API_KEY" + +#: In-DTU seed directory and the host config pushed into it by conftest. +DTU_DIR = "/root/e2e/gemini" +CONFIG_PATH = f"{DTU_DIR}/host-config-gemini.json" + +#: Single literal the smoke prompt asks for. Short, unambiguous, and unlikely +#: to appear by accident in a refusal or an error string. +PING_TOKEN = "PONG" + + +def _providers_rows(parsed: Any) -> list[dict[str, Any]]: + """Pull the row list out of a ``providers list --json`` payload.""" + assert isinstance(parsed, dict), f"providers list did not emit a JSON object: {parsed!r}" + rows = parsed.get("providers") + assert isinstance(rows, list), f"payload has no 'providers' list: {parsed!r}" + return [r for r in rows if isinstance(r, dict)] + + +def expect_gemini_registered(parsed: Any) -> None: + """gemini is in the credential report, wired to the right module and var. + + Split into separate assertions because the four failures are genuinely + different problems: not in KNOWN_PROVIDERS, wrong catalog entry, wrong + credential variable, and "the DTU never received the key". The last one is + the reason this suite has no separate ghcp-style token guard test -- this + case already proves the passthrough reached the container. + """ + rows = _providers_rows(parsed) + names = [r.get("name") for r in rows] + + assert PROVIDER_ID in names, ( + f"{PROVIDER_ID!r} missing from `providers list`; it is not in KNOWN_PROVIDERS. Saw: {names}" + ) + + row = next(r for r in rows if r.get("name") == PROVIDER_ID) + + assert row.get("module") == PROVIDER_MODULE, ( + f"{PROVIDER_ID} maps to module {row.get('module')!r}, expected {PROVIDER_MODULE!r}" + ) + assert row.get("env_var") == CREDENTIAL_VAR, ( + f"{PROVIDER_ID} reports credential var {row.get('env_var')!r}, expected {CREDENTIAL_VAR!r}" + ) + assert row.get("resolvable") is True and row.get("source") == "env", ( + f"{PROVIDER_ID} did not resolve from the environment " + f"(resolvable={row.get('resolvable')!r}, source={row.get('source')!r}). " + f"{CREDENTIAL_VAR} is not reaching the DTU even though the suite guard saw it." + ) + + +def expect_gemini_models(parsed: Any) -> None: + """``models list --provider gemini`` returns a usable live listing.""" + assert isinstance(parsed, dict), f"models list did not emit a JSON object: {parsed!r}" + assert parsed.get("schema_version") == 1, f"unexpected schema_version: {parsed.get('schema_version')!r}" + assert parsed.get("provider") == PROVIDER_ID, f"payload reports provider {parsed.get('provider')!r}" + + models = parsed.get("models") + assert isinstance(models, list) and models, ( + f"{PROVIDER_ID} returned no models. An empty list is a legal answer for some providers, " + f"but not for gemini: it means the live query reached nothing usable. Payload: {parsed!r}" + ) + missing = [m for m in models if not (isinstance(m, dict) and m.get("id"))] + assert not missing, f"model entries without an 'id': {missing!r}" + + +def expect_config_accepts_gemini(parsed: Any) -> None: + """A host config naming ``gemini`` parses cleanly. + + ``config show`` is a diagnostic command and always exits 0, capturing a + rejection into ``host_config.parse_error`` rather than a non-zero exit. So + the absence of that key is the actual assertion: it is what proves + ``gemini`` is in the closed ``provider.module`` set. + """ + assert isinstance(parsed, dict), f"config show did not emit a JSON object: {parsed!r}" + host_config = parsed.get("host_config") + assert isinstance(host_config, dict), f"payload has no 'host_config' block: {parsed!r}" + + assert "parse_error" not in host_config, ( + f"host config naming provider.module={PROVIDER_ID!r} was rejected: {host_config.get('parse_error')!r}. " + f"Expected it to be accepted (error code config_invalid_provider_module means the " + f"provider is missing from the valid module set)." + ) + + provider_block = (host_config.get("parsed") or {}).get("provider") + assert isinstance(provider_block, dict) and provider_block.get("module") == PROVIDER_ID, ( + f"parsed host config does not report provider.module={PROVIDER_ID!r}: {provider_block!r}" + ) + + +#: Catalog and credential wiring. No model is invoked. +WIRING: list[E2ECase] = [ + E2ECase( + "gemini-providers-list", + "cli", + ["providers", "list", "--json"], + check=expect_gemini_registered, + ), + E2ECase( + "gemini-config-accepted", + "cli", + ["config", "show", "--config", CONFIG_PATH], + check=expect_config_accepts_gemini, + ), +] + +#: Live query against Google, but no completion. +MODELS: list[E2ECase] = [ + E2ECase( + "gemini-models-list", + "cli", + ["models", "list", "--provider", PROVIDER_ID, "--output", "json"], + check=expect_gemini_models, + ), +] + +#: One real session. The cheapest possible proof that the provider module +#: mounts, authenticates, and completes end to end. +SMOKE: list[E2ECase] = [ + E2ECase( + "gemini-basic-reply", + "cli", + [ + "run", + "-y", + "--config", + CONFIG_PATH, + f"Reply with exactly the word {PING_TOKEN} and nothing else.", + ], + check=expect_contains(PING_TOKEN), + ), +] diff --git a/tests/e2e/suites/gemini/conftest.py b/tests/e2e/suites/gemini/conftest.py new file mode 100644 index 00000000..d4106ac5 --- /dev/null +++ b/tests/e2e/suites/gemini/conftest.py @@ -0,0 +1,61 @@ +"""Suite fixtures: credential gating and host-config seeding. + +Unlike ``github_copilot``, which fails loud on a missing credential, this suite +SKIPS. The two suites are answering different questions. github_copilot exists +to exercise a provider someone deliberately set out to test, so a missing token +there is a setup mistake worth stopping on. gemini's cases mostly guard the +provider catalog, which everyone touches, so this suite gets pulled into every +full ``cli.py run``. Failing the whole run for a contributor who has no Google +key and no interest in gemini would make the default run red for a reason that +is not their change. + +So: no key, no run, no failure. The skip message says exactly what to do. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from framework import dtu + +from suites.gemini.cases import CONFIG_PATH, CREDENTIAL_VAR + +FIXTURES = Path(__file__).parent / "fixtures" + +#: Probes for the variable's presence inside the container without ever echoing +#: its value, so it cannot leak into a pytest report or a CI log. +_PROBE = f'if [ -n "${CREDENTIAL_VAR}" ]; then echo key=present; else echo key=missing; fi' + +_SKIP_REASON = ( + f"{CREDENTIAL_VAR} is not set inside the DTU, so the gemini suite is skipped. " + f"To run it: export {CREDENTIAL_VAR} on the host and re-provision " + f"(`uv run python tests/e2e/framework/cli.py up`). The value is snapshotted into the " + f"container at launch, so exporting it against an already-running DTU has no effect." +) + + +@pytest.fixture(scope="session") +def gemini_key(dtu_id: str) -> None: + """Skip the whole suite unless the credential actually reached the DTU. + + Checked inside the container rather than on the host, because that is where + it matters: the host can have the variable exported and the DTU still not + have it if the container predates the export. + """ + result = dtu.exec_json(dtu_id, ["bash", "-lc", _PROBE]) + if "key=present" not in str(result.get("stdout", "")): + pytest.skip(_SKIP_REASON) + + +@pytest.fixture(scope="session") +def gemini_config(dtu_id: str, gemini_key: None) -> str: + """Push the gemini host config into the DTU and return its in-container path. + + The fixture deliberately omits ``provider.config.default_model``. The agent + holds no static model table (see docs/spec/providers-and-models.md), so the + provider module supplies its own default; pinning an id here would rot the + moment Google renames a model. + """ + dtu.push_file(dtu_id, str(FIXTURES / "host-config-gemini.json"), CONFIG_PATH) + return CONFIG_PATH diff --git a/tests/e2e/suites/gemini/fixtures/host-config-gemini.json b/tests/e2e/suites/gemini/fixtures/host-config-gemini.json new file mode 100644 index 00000000..d0131cb5 --- /dev/null +++ b/tests/e2e/suites/gemini/fixtures/host-config-gemini.json @@ -0,0 +1,8 @@ +{ + "provider": { + "module": "gemini" + }, + "approval": { + "mode": "yes" + } +} diff --git a/tests/e2e/suites/gemini/test_gemini.py b/tests/e2e/suites/gemini/test_gemini.py new file mode 100644 index 00000000..d380026d --- /dev/null +++ b/tests/e2e/suites/gemini/test_gemini.py @@ -0,0 +1,41 @@ +"""End-to-end coverage for the ``gemini`` provider. + +Proves the four things amplifier-agent became responsible for when gemini was +registered as the eighth provider: the credential report knows it, its models +list, a host config naming it validates, and a session runs on it. + +The suite skips wholesale when GOOGLE_API_KEY is absent from the DTU. See +``conftest.py`` for why this differs from github_copilot's fail-loud guard. +""" + +from __future__ import annotations + +import pytest +from framework import harness +from framework.harness import E2ECase + +from suites.gemini.cases import MODELS, SMOKE, WIRING + +pytestmark = pytest.mark.dtu + + +def _ids(cases: list[E2ECase]) -> list[str]: + return [c.name for c in cases] + + +@pytest.mark.parametrize("case", WIRING, ids=_ids(WIRING)) +def test_gemini_wiring(case: E2ECase, dtu_id: str, gemini_config: str) -> None: + """Catalog registration, credential variable, and config validation.""" + harness.run_cli_case(dtu_id, case) + + +@pytest.mark.parametrize("case", MODELS, ids=_ids(MODELS)) +def test_gemini_models_list(case: E2ECase, dtu_id: str, gemini_config: str) -> None: + """A live model listing comes back non-empty and well-formed.""" + harness.run_cli_case(dtu_id, case) + + +@pytest.mark.parametrize("case", SMOKE, ids=_ids(SMOKE)) +def test_gemini_basic_reply(case: E2ECase, dtu_id: str, gemini_config: str) -> None: + """One real session completes end to end against the provider module.""" + harness.run_cli_case(dtu_id, case)