Skip to content
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
calls a stated endpoint too, as the routing model already did; it called the provider's
default address instead.

### Security

- **`FLUID_LLM_API_KEY` goes only to a provider you name.** The key does not say which vendor it
is for, yet forge sent it to whatever provider it picked, including one it only inferred: a
running Ollama or the one `OLLAMA_HOST` names (as `Authorization: Bearer`, in cleartext when
the host is http), the provider of the one key saved in the keyring, or the provider of the one
key variable set. So an OpenAI key could reach a local or remote Ollama, or Anthropic. Forge
now uses it only when `--llm-provider` or `FLUID_LLM_PROVIDER` names the provider the run uses.
A provider forge picks itself gets its own key variable, then its keyring key, and a warning
says the generic key was not used. In interactive `fluid forge` and `fluid init`, the provider
that setup finds counts as picked, not named. `fluid ai test` reads the key only with
`--llm-provider`, and `fluid doctor` counts it only with `FLUID_LLM_PROVIDER`. A named Ollama
still gets the key (ollama.com, a token proxy). If you used the key with a provider forge
inferred, name that provider: `--llm-provider <name>` or `FLUID_LLM_PROVIDER=<name>`.
- **A project `.env` can no longer pair an OpenAI, Anthropic, Gemini or GitHub endpoint with
`FLUID_LLM_API_KEY`.** Forge can pick these providers without a name, and then it sends the
operator's keyring key, not the project's `FLUID_LLM_API_KEY`, so `OPENAI_BASE_URL`,
`ANTHROPIC_BASE_URL`, `GEMINI_API_BASE`, `GITHUB_API_BASE` and the like in a project file now
need the provider's own key variable beside them (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`GEMINI_API_KEY` or `GOOGLE_API_KEY`, `GITHUB_API_KEY`). Endpoints of providers forge reaches
only by name (`GROQ_API_BASE`, `DEEPSEEK_API_BASE` ...) still pair with it.
- **Interactive setup no longer sends your saved key to a host a project's `.env` names.** A
project file may point a provider at its own host (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`,
`GEMINI_API_BASE`, `GITHUB_API_BASE`) next to a key of its own. Interactive `fluid forge` and
`fluid init` then loaded the provider `fluid ai setup` saved and copied its keyring key into the
provider's key variable, over the project's key (for Gemini into `GOOGLE_API_KEY`, which forge
reads first), so your key went to the project's host. While a project endpoint of that provider
is in effect, setup now leaves the variable alone, the project's own key goes there, and a
warning says your key was not used. `fluid ai setup` no longer checks a typed or imported key
against such a host either: the check is one litellm call, which reads the endpoint from the
environment. It now asks for no key there, skips a key it would import, and says to run
`fluid ai setup` outside the project. For a provider other than OpenAI, Anthropic, Gemini and
GitHub, any LLM endpoint a project file set counts.

## [0.28.0] — 2026-11-06

### Added
Expand Down
18 changes: 13 additions & 5 deletions fluid_build/cli/_ai_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,16 @@ def _validate_ai_test_endpoint(provider_name: str, endpoint: str) -> Optional[st
return None


def _resolve_cloud_api_key(provider: str, saved: Optional[dict] = None) -> Optional[str]:
if os.environ.get("FLUID_LLM_API_KEY"):
def _resolve_cloud_api_key(
provider: str, saved: Optional[dict] = None, *, named: bool = False
) -> Optional[str]:
"""The key ``fluid ai test`` sends to *provider*.

``FLUID_LLM_API_KEY`` counts only when *named* (``--llm-provider``): it does
not say which vendor it is for, so a saved or env-inferred provider never
gets it (``llm/provider_resolve._resolve_api_key``).
"""
if named and os.environ.get("FLUID_LLM_API_KEY"):
return os.environ["FLUID_LLM_API_KEY"]
if provider == "gemini":
for env_var in ("GEMINI_API_KEY", "GOOGLE_API_KEY"):
Expand Down Expand Up @@ -236,7 +244,7 @@ def _resolve_ai_test_config(args: Any) -> tuple[Optional[LlmConfig], Optional[st
saved = _load_ai_config() or {}

def _build_for_provider(
pname: str, *, allow_saved: bool = True
pname: str, *, allow_saved: bool = True, named: bool = False
) -> tuple[Optional[LlmConfig], Optional[str]]:
provider = BUILTIN_LLM_PROVIDERS.get(pname)
if not provider:
Expand Down Expand Up @@ -264,7 +272,7 @@ def _build_for_provider(
),
None,
)
api_key = _resolve_cloud_api_key(pname, saved_for_provider)
api_key = _resolve_cloud_api_key(pname, saved_for_provider, named=named)
if not api_key:
return None, f"No API key found for {PROVIDER_DISPLAY_NAMES.get(pname, pname)}."
endpoint = endpoint_override or saved_for_provider.get("endpoint")
Expand All @@ -279,7 +287,7 @@ def _build_for_provider(
)

if provider_override:
config, error = _build_for_provider(provider_override)
config, error = _build_for_provider(provider_override, named=True)
if config:
config.timeout_seconds = timeout
return config, error
Expand Down
115 changes: 110 additions & 5 deletions fluid_build/cli/ai_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,15 @@ def _validate_api_key(provider: Any, api_key: str) -> Optional[str]:
renders as a one-line install hint.

Returns ``None`` on success or a short human-readable error message.

Nothing is sent while a project dotenv file points the provider at its own
host: litellm reads that endpoint from the environment, so the check would
hand the operator's key (typed, or imported from another tool) to it.
"""
notice = _project_host_notice(provider.name.lower())
if notice:
return f"not checked: {notice}"

from fluid_build._optional import require

litellm = require("litellm", "litellm", "Validating an AI provider key")
Expand Down Expand Up @@ -272,21 +280,108 @@ def _validate_api_key(provider: Any, api_key: str) -> Optional[str]:
return f"validation failed: {exc}"


# The ``credentials.protected_env`` endpoint group of each provider whose
# PROVIDER_ENV_VARS name ``set_session_env`` writes.
_PROVIDER_ENDPOINT_SERVICES = {
"openai": "OpenAI",
"anthropic": "Anthropic",
"gemini": "Google Gemini",
"github": "GitHub Models",
}


def _endpoint_service(provider: str) -> Optional[str]:
"""The ``credentials.protected_env`` group of *provider*, or ``None``.

Picked by comparison, so the result is the table's own constant. A provider
name can come from classifying a typed key (``_classify_key_shape``), and
what setup logs must not be derived from a key (CodeQL
py/clear-text-logging-sensitive-data).
"""
return next(
(service for name, service in _PROVIDER_ENDPOINT_SERVICES.items() if name == provider),
None,
)


def _project_llm_endpoints(provider: str) -> List[str]:
"""The endpoint variables in effect that project dotenv files point *provider* at.

``credentials.protected_env`` loaded such an endpoint only because the
project files supply a key for it, the one its readers send there. For a
provider whose key ``set_session_env`` writes, the endpoints of its own
group. For any other provider, every LLM endpoint the project set (the
groups that list FLUID_LLM_API_KEY, which every LLM group does, and the
litellm names no group lists): litellm's routes are not mapped one by one.
"""
from fluid_build.credentials import dotenv_store

service = _endpoint_service(provider)
if service is not None:
return dotenv_store.project_endpoints_in_effect(service)
from fluid_build.credentials.protected_env import (
ENDPOINT_CREDENTIAL_GROUPS,
UNLISTED_LLM_ENDPOINTS,
is_endpoint_of,
)

groups = [g for g in ENDPOINT_CREDENTIAL_GROUPS if "FLUID_LLM_API_KEY" in g.credentials]
groups.append(UNLISTED_LLM_ENDPOINTS)
return sorted(
name
for name in dotenv_store.project_values_in_effect()
if any(is_endpoint_of(group, name) for group in groups)
)


def _project_host_notice(provider: str) -> Optional[str]:
"""Why setup sends no key of the operator's for *provider*, or ``None``.

Built from the endpoint group's name and the endpoint variables' names, never
from *provider* itself or a key, because it is logged (CodeQL
py/clear-text-logging-sensitive-data).
"""
names = _project_llm_endpoints(provider)
if not names:
return None
who = _endpoint_service(provider) or "this LLM provider"
return (
f"a project dotenv file points {who} at its own host ({', '.join(names)}), "
"so your key is not sent there; run 'fluid ai setup' outside this project"
)


def _warn_key_not_used(provider: str) -> None:
"""Log that the operator's key for *provider* is not used for this run."""
notice = _project_host_notice(provider)
if notice:
LOG.warning("Your key is not used: %s.", notice)


def set_session_env(provider: str, api_key: str) -> None:
"""Set the provider-specific env var for the current process only.

This is necessary so that ``resolve_llm_config()`` can find the key
during this session. The key is **not** written to disk or exported
to child processes beyond the current process tree.

Not while a project dotenv file points the provider at its own host: the
key is the operator's (keyring, ``ai_config.json``, a prompt, another
tool's login), and the readers would send it there in place of the key
the project paired that host with.
"""
from fluid_build.cli.ai_setup import PROVIDER_ENV_VARS

env_var = PROVIDER_ENV_VARS.get(provider)
if env_var:
os.environ[env_var] = api_key
# Don't interpolate env_var/provider — ``api_key`` is in scope
# at this LOG site (CodeQL py/clear-text-logging-sensitive-data).
LOG.debug("Set session env var (provider configured)")
if not env_var:
return
if _project_llm_endpoints(provider):
_warn_key_not_used(provider)
return
os.environ[env_var] = api_key
# Don't interpolate env_var/provider — ``api_key`` is in scope
# at this LOG site (CodeQL py/clear-text-logging-sensitive-data).
LOG.debug("Set session env var (provider configured)")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -579,6 +674,12 @@ def _collect_and_validate_api_key(
label = PROVIDER_DISPLAY_NAMES.get(provider_choice, provider_choice)
signup_url = _SIGNUP_URLS.get(provider_choice, "")
last_error: Optional[str] = None
# A key typed here could be neither checked nor used (_validate_api_key,
# set_session_env), so ask for none.
notice = _project_host_notice(provider_choice)
if notice:
console.print(literal_error_text(f"Not asking for a {label} key: {notice}.", "yellow"))
return None

for attempt in range(1, max_attempts + 1):
url_hint = (
Expand Down Expand Up @@ -1250,6 +1351,10 @@ def _import_and_persist(console: Any, candidate: _ImportedKey) -> Optional[LlmCo
if provider_obj is None:
console.print(f"[yellow]Could not initialise {label}; skipping.[/yellow]")
return None
notice = _project_host_notice(provider_obj.name.lower())
if notice: # the key could be neither checked nor used (_validate_api_key)
console.print(literal_error_text(f"Skipping the {label} key: {notice}.", "yellow"))
return None
console.print("[dim]Verifying the imported key...[/dim]")
# Don't interpolate the key at any LOG site in this frame
# (CodeQL py/clear-text-logging-sensitive-data — ``candidate.key`` in scope).
Expand Down
5 changes: 5 additions & 0 deletions fluid_build/cli/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1879,6 +1879,11 @@ def _run_main(args, logger: logging.Logger) -> int:
# 3. Nothing found → prompts user interactively
llm_config = run_ai_setup_inline(console)
if llm_config:
from fluid_build.llm.providers import remember_named_llm_provider

# First keep what the operator named: FLUID_LLM_API_KEY goes only to
# that provider, never to the one setup found.
remember_named_llm_provider(args)
# Inject into args so copilot's resolve_llm_config() finds them
args.llm_provider = llm_config.provider
args.llm_model = llm_config.model
Expand Down
10 changes: 9 additions & 1 deletion fluid_build/cli/forge_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ def _forge_build_copilot_options(
"""
from fluid_build.llm.providers import agent_loop_requested

return {
options = {
"llm_provider": get_cli_arg_fn(args, "llm_provider"),
"llm_model": get_cli_arg_fn(args, "llm_model"),
"llm_endpoint": get_cli_arg_fn(args, "llm_endpoint"),
Expand Down Expand Up @@ -357,6 +357,14 @@ def _forge_build_copilot_options(
"seed_allow_remote": bool(get_cli_arg_fn(args, "seed_allow_remote", False)),
"seed_no_remote": bool(get_cli_arg_fn(args, "seed_no_remote", False)),
}
# Interactive setup may have put the provider it found into llm_provider.
# Carry what the operator named, so FLUID_LLM_API_KEY still goes only to that
# provider (providers.remember_named_llm_provider).
not_recorded = object()
named = get_cli_arg_fn(args, "llm_provider_named", not_recorded)
if named is not not_recorded:
options["llm_provider_named"] = named
return options


def _forge_install_pause_handler(
Expand Down
4 changes: 4 additions & 0 deletions fluid_build/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,10 @@ def _ai_mode(args, logger: logging.Logger) -> int:
_console = console if RICH_AVAILABLE else None
llm_config = run_ai_setup_inline(_console)
if llm_config:
from fluid_build.llm.providers import remember_named_llm_provider

# FLUID_LLM_API_KEY goes only to a provider the operator named.
remember_named_llm_provider(args)
args.llm_provider = llm_config.provider
args.llm_model = llm_config.model
args.llm_endpoint = llm_config.endpoint
Expand Down
15 changes: 8 additions & 7 deletions fluid_build/credentials/litellm_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
"""Where litellm reads an LLM provider's endpoint, vendored.

``credentials.protected_env`` pairs these with ``FLUID_LLM_API_KEY`` only:
FLUID routes any provider litellm knows and passes it the key it resolved
(``FLUID_LLM_API_KEY``, else the keyring), which beats a provider key in the
project's files. A group in ``protected_env`` that lists one of the names
(``IBM watsonx``, ``OpenAI`` ...) pairs it by its own rule instead, and a name
litellm sends something else to (``LITELLM_PROXY_API_BASE``, the GitHub
Copilot ones) is protected outright. Any other ``<NAME>_API_BASE`` is the
project's own setting and follows the name-shape rule.
FLUID routes any provider litellm knows, only when the operator names it, and
passes it the key it resolved (``FLUID_LLM_API_KEY``, else the keyring), which
beats a provider key in the project's files. A group in ``protected_env`` that
lists one of the names (``IBM watsonx``, ``OpenAI`` ...) pairs it by its own
rule instead, and a name litellm sends something else to
(``LITELLM_PROXY_API_BASE``, the GitHub Copilot ones) is protected outright.
Any other ``<NAME>_API_BASE`` is the project's own setting and follows the
name-shape rule.

Vendored because importing litellm takes seconds and dotenv hydration runs on
every command. Generated from the litellm 1.83.14, 1.97.0, 1.105.0, 1.106.0,
Expand Down
Loading
Loading