Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
script needs a SQL warehouse that runs SQL scripting. A dbt build that
`--mode amend-and-build` runs locally is followed by the same re-declaration.

### Changed

- **Keys for two or more providers stop the run, even with Ollama running.** With
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY` both set (or one provider's key and `OLLAMA_HOST`) and
nothing naming a provider, a run without a TTY skipped both keys and used a running local
Ollama, and interactive setup used the first key it found. Forge now stops with
`copilot_no_llm_provider` and names the keys, without asking Ollama; interactive setup,
`fluid ai test` and `fluid doctor` say the same and use none of them, and `fluid init`'s AI
path exits 1 where it used to write a template instead. A `fluid ai setup`
choice and a single keyring key still come first, and `GEMINI_API_KEY` with `GOOGLE_API_KEY` is
one provider. Pick one with `--llm-provider` or `FLUID_LLM_PROVIDER`.

### Fixed

- **`fluid ai models --provider` works, and takes the names `fluid forge --llm-provider`
Expand Down Expand Up @@ -74,6 +86,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`forge.contract_written` and the receipt sent another, so the stream named one file twice,
first by its absolute path and then by its path from the current directory. Only the receipt's
event is left, as in every other mode.
- **Interactive `fluid forge` keeps the provider, model and endpoint you state.** Its inline
setup put the saved or first-found provider over `--llm-provider`, `--llm-model` and
`--llm-endpoint`, and over `FLUID_LLM_PROVIDER`, `FLUID_FORGE_AGENT`, `FLUID_LLM_MODEL` and
`FLUID_LLM_ENDPOINT`, so `fluid forge --llm-provider ollama` could call Anthropic. A run that
names its provider now skips inline setup and resolves as a run without a TTY does. Otherwise
setup fills only what the run leaves unstated, and an agent it finds is not used when the run
states an endpoint or a model, or runs the agent loop. The welcome panel and the interview's
"What I see" panel name the provider the run uses, not the saved one. `fluid init`'s AI path
does the same, and its handoff no longer offers AI setup for a provider named by a variable.
- **Interactive setup no longer picks GitHub Models from `GITHUB_API_KEY`.** It took any variable
in `PROVIDER_ENV_VARS`, so `GITHUB_API_KEY` alone selected github, which setup then failed to
build (`KeyError: 'github'`). It now reads the key variables a run without a TTY reads:
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY` or `GEMINI_API_KEY` (it missed the
second). GitHub Models stays a provider you name with `--llm-provider github`. `fluid ai test`
and `fluid init`'s check for a configured AI follow the same rule.
- **`fluid ai test` tests the provider `FLUID_LLM_PROVIDER` or `FLUID_FORGE_AGENT` names.** It
read only `--llm-provider`, so with `FLUID_LLM_PROVIDER=anthropic` it tested the saved
provider, which forge would not use. A provider named this way also gets `FLUID_LLM_API_KEY`,
as with the flag. A coding agent or `mcp-sampling` named either way is not tested, and the
command now says so, where `claude-code` failed as "Unsupported AI provider 'claude_code'".
- **A contract that types its text columns `STRING` passes the DuckDB acquisition's schema
gate.** DuckDB reports every text column as `VARCHAR`, and the gate read `STRING` and `VARCHAR`
as two types, so a contract written for Spark or Databricks stopped every ingest with "source
Expand Down
92 changes: 69 additions & 23 deletions fluid_build/cli/_ai_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,10 @@ def _resolve_cloud_api_key(
) -> 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``).
``FLUID_LLM_API_KEY`` counts only when *named* (``--llm-provider``,
``FLUID_LLM_PROVIDER``, ``FLUID_FORGE_AGENT``): 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"]
Expand All @@ -234,8 +235,63 @@ def _resolve_cloud_api_key(
return None


def _untested_agent_message(named: Optional[str]) -> Optional[str]:
"""Why ``fluid ai test`` does not test *named*, when it is an agent; else ``None``.

Forge hands an agent's calls to its CLI, or to the IDE for ``mcp-sampling``,
so there is no API call to test. Any spelling forge accepts counts
(``MCP-Sampling``, ``claude_code``): the registry does not list
``mcp-sampling``, so the name is normalized here.
"""
# From ``providers``, never ``provider_resolve`` first (a circular import).
from fluid_build.llm.providers import _NON_LITELLM_PROVIDERS, _canonical_provider_name

agent = (_canonical_provider_name(named) or "").lower().replace("_", "-")
if agent not in _NON_LITELLM_PROVIDERS:
return None
if agent == "mcp-sampling":
return (
"fluid ai test does not test mcp-sampling: the IDE that runs forge's MCP "
"server answers those calls with its own model."
)
return (
f"fluid ai test does not test {agent}: forge hands its calls to that agent's "
"CLI, which signs in by itself. 'fluid doctor' checks that the CLI is installed."
)


def _key_variable_choice() -> tuple[Optional[str], Optional[str]]:
"""The provider the key variables select, as in forge, else why none.

One provider's key variables select it (never ``GITHUB_API_KEY``;
``OLLAMA_HOST`` is checked after this). Two or more providers' select none:
exactly one keyring key then decides. Returns ``(provider, None)``,
``(None, message)`` to stop, or ``(None, None)`` when no key variable applies.
"""
# From ``providers``, never ``provider_resolve`` first (a circular import).
from fluid_build.llm.providers import (
_explicit_key_signals,
_infer_provider_from_keyring,
ambiguous_keys_message,
)

ambiguous = ambiguous_keys_message(os.environ)
if ambiguous:
kept = _infer_provider_from_keyring()
return (kept, None) if kept else (None, ambiguous)
return next((p for p in _explicit_key_signals(os.environ) if p != "ollama"), None), None


def _resolve_ai_test_config(args: Any) -> tuple[Optional[LlmConfig], Optional[str]]:
provider_override = _normalize_ai_test_provider(getattr(args, "llm_provider", None))
from fluid_build.llm.providers import named_llm_provider

# The provider the run names, read as forge reads it: --llm-provider, then
# FLUID_LLM_PROVIDER, then FLUID_FORGE_AGENT.
named = named_llm_provider(args, os.environ)
untested = _untested_agent_message(named)
if untested:
return None, untested
provider_override = _normalize_ai_test_provider(named)
model_override = getattr(args, "llm_model", None) or os.environ.get("FLUID_LLM_MODEL")
endpoint_override = getattr(args, "llm_endpoint", None) or os.environ.get("FLUID_LLM_ENDPOINT")
timeout = _coerce_ai_test_timeout(
Expand Down Expand Up @@ -277,40 +333,30 @@ def _build_for_provider(
return None, f"No API key found for {PROVIDER_DISPLAY_NAMES.get(pname, pname)}."
endpoint = endpoint_override or saved_for_provider.get("endpoint")
config = _make_cloud_config(pname, api_key, model=model, endpoint=endpoint)
config.timeout_seconds = timeout
if config.endpoint:
endpoint_error = _validate_ai_test_endpoint(pname, config.endpoint)
if endpoint_error:
return None, endpoint_error
return (
config,
None,
)
return config, None

if provider_override:
config, error = _build_for_provider(provider_override, named=True)
if config:
config.timeout_seconds = timeout
return config, error
return _build_for_provider(provider_override, named=True)

if saved.get("provider"):
config, error = _build_for_provider(str(saved["provider"]))
if config:
config.timeout_seconds = timeout
return config, None
LOG.debug("Saved AI config was not test-ready: %s", error)

for pname, env_var in PROVIDER_ENV_VARS.items():
if os.environ.get(env_var) or (pname == "gemini" and os.environ.get("GEMINI_API_KEY")):
config, error = _build_for_provider(pname, allow_saved=False)
if config:
config.timeout_seconds = timeout
return config, error
pname, stop = _key_variable_choice()
if stop:
return None, stop
if pname:
return _build_for_provider(pname, allow_saved=False)

if os.environ.get("OLLAMA_HOST") or detect_ollama_available(os.environ):
config, error = _build_for_provider("ollama", allow_saved=False)
if config:
config.timeout_seconds = timeout
return config, error
return _build_for_provider("ollama", allow_saved=False)

return None, "No AI provider configured. Run 'fluid ai setup' or set a provider API key."

Expand Down
12 changes: 12 additions & 0 deletions fluid_build/cli/_world_class_interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ def collect_signals(
target_dir: Optional[Path] = None,
project_memory: Any = None,
active_agent: str = "",
ai_provider: Optional[str] = None,
) -> InterviewSignals:
"""Pull every signal the system already has into a single record.

Expand All @@ -261,6 +262,9 @@ def collect_signals(
data-product-type picker to the codes the agent's spec declares
in ``supported_data_product_types``. Empty / unknown agent names
leave the picker unfiltered (all three types).

``ai_provider``: the provider the run resolved. It replaces the scan's
guess, which reads the saved choice and cannot see ``--llm-provider``.
"""
cwd = (target_dir or Path.cwd()).resolve()
sig = InterviewSignals(cwd_name=cwd.name, active_agent=active_agent)
Expand Down Expand Up @@ -289,6 +293,9 @@ def collect_signals(
sig.ai_provider = findings.ai_provider_hint
except Exception as exc: # noqa: BLE001
LOG.debug("welcome_scan_unavailable: %s", exc)
if isinstance(ai_provider, str) and ai_provider:
sig.ai_configured = True
sig.ai_provider = ai_provider

try:
from fluid_build.cli.workspace_config import (
Expand Down Expand Up @@ -439,6 +446,7 @@ def run_world_class_bootstrap(
project_memory: Any = None,
auto_mode: bool = False,
active_agent: str = "",
ai_provider: Optional[str] = None,
) -> None:
"""Drive the world-class interview against ``state``.

Expand All @@ -451,6 +459,9 @@ def run_world_class_bootstrap(
``supported_data_product_types`` allowlist. The interview's
inference path falls back to the first allowed type when the
workspace lock or scan-suggested type is forbidden by the agent.

``ai_provider``: the provider the run uses, shown in the "What I see"
panel (see :func:`collect_signals`).
"""
from fluid_build.cli.forge_dialogs import ask_friendly_text

Expand All @@ -462,6 +473,7 @@ def run_world_class_bootstrap(
target_dir=target_dir,
project_memory=project_memory,
active_agent=active_agent,
ai_provider=ai_provider,
)

# Pre-fill anything we can infer without asking, BEFORE rendering
Expand Down
88 changes: 72 additions & 16 deletions fluid_build/cli/ai_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,24 +1440,87 @@ def _offer_import_from_other_clis(console: Any) -> Optional[LlmConfig]:
return None


# What :func:`_setup_from_key_variables` returns when setup must stop.
_STOP_SETUP = object()


def _setup_from_key_variables(console: Any) -> Any:
"""Step 2 of :func:`run_ai_setup_inline`: the key variables a run without a TTY reads.

``OPENAI_API_KEY``, ``ANTHROPIC_API_KEY`` and ``GEMINI_API_KEY`` /
``GOOGLE_API_KEY`` select their provider. ``GITHUB_API_KEY`` never does:
github is used only when named (``PROVIDER_ENV_VARS``). ``OLLAMA_HOST`` is
step 3. Variables for two or more providers select none: as without a TTY,
exactly one keyring key then decides, else this names them.

Returns the config, :data:`_STOP_SETUP` when setup must stop, or ``None`` to
go on to step 3.
"""
# Resolve deferred symbols via the module ``__getattr__`` lazy-import seam.
from fluid_build.cli.ai_setup import PROVIDER_DISPLAY_NAMES, PROVIDER_ENV_VARS
Comment thread
fas89 marked this conversation as resolved.
Resolved

# From ``providers``, never ``provider_resolve`` first (a circular import).
from fluid_build.llm.providers import (
_explicit_key_signals,
_infer_provider_from_keyring,
_resolve_api_key,
ambiguous_keys_message,
)

ambiguous = ambiguous_keys_message(os.environ)
if ambiguous:
kept = _infer_provider_from_keyring()
kept_key = _resolve_api_key(kept, os.environ) if kept else None
if kept and kept_key:
if console and RICH_AVAILABLE:
label = PROVIDER_DISPLAY_NAMES.get(kept, kept)
console.print(f"[dim]Using {label}, the one provider with a keyring key.[/dim]")
LOG.info("Inline AI setup: several key variables, one keyring key")
return _make_cloud_config(kept, kept_key)
if console and RICH_AVAILABLE:
console.print(literal_error_text(ambiguous, "yellow"))
LOG.info("Inline AI setup: key variables for several providers, none chosen")
return _STOP_SETUP
signals = _explicit_key_signals(os.environ) # one provider at most here
pname = next(iter(signals), None)
if not pname or pname == "ollama": # nothing, or OLLAMA_HOST: step 3
return None
# GOOGLE_API_KEY before GEMINI_API_KEY, as the key resolver reads them.
env_var = PROVIDER_ENV_VARS.get(pname)
if not env_var or not os.environ.get(env_var):
env_var = signals[pname][0]
env_key = os.environ[env_var]
if console and RICH_AVAILABLE:
label = PROVIDER_DISPLAY_NAMES.get(pname, pname)
console.print(f"[dim]Using {label} from environment.[/dim]")
LOG.info("Inline AI setup: loaded %s from env var", pname)
# 2a. Opt-in import: the key is in this shell's environment
# (e.g. exported for another tool). Offer to persist it to
# fluid's own keyring so it survives the next shell — strictly
# opt-in, never in CI, and only the env value we already read.
_offer_import_env_api_key(console, pname, env_key, env_var)
return _make_cloud_config(pname, env_key)


def run_ai_setup_inline(console: Any) -> Optional[LlmConfig]:
"""Compact inline setup triggered when forge starts without a configured provider.

Resolves an LLM config in this priority order:

1. saved config file + OS keyring (``~/.fluid/ai_config.json``)
2. cloud provider env vars (``OPENAI_API_KEY`` etc.)
2. cloud provider env vars (``OPENAI_API_KEY`` etc.), never ``GITHUB_API_KEY``
3. explicit ``OLLAMA_HOST`` env var
4. auto-detected local Ollama (with user confirmation on TTY)
5. interactive provider picker

Returns ``None`` if the user skips setup or stdin is not a TTY.
Returns ``None`` if the user skips setup, stdin is not a TTY, or key
variables name two or more providers (setup names them instead of choosing).
Callers do not run it for a provider the operator named.
"""
# Resolve deferred symbols via the module ``__getattr__`` lazy-import seam.
from fluid_build.cli.ai_setup import (
BUILTIN_LLM_PROVIDERS,
PROVIDER_DISPLAY_NAMES,
PROVIDER_ENV_VARS,
ask_confirmation,
detect_ollama_available,
)
Expand Down Expand Up @@ -1518,19 +1581,12 @@ def run_ai_setup_inline(console: Any) -> Optional[LlmConfig]:

# 2. Check cloud-provider env vars (e.g. CI environments that
# inject ANTHROPIC_API_KEY / OPENAI_API_KEY directly).
for pname, env_var in PROVIDER_ENV_VARS.items() if has_litellm else ():
env_key = os.environ.get(env_var)
if env_key:
if console and RICH_AVAILABLE:
label = PROVIDER_DISPLAY_NAMES.get(pname, pname)
console.print(f"[dim]Using {label} from environment.[/dim]")
LOG.info("Inline AI setup: loaded %s from env var", pname)
# 2a. Opt-in import: the key is in this shell's environment
# (e.g. exported for another tool). Offer to persist it to
# fluid's own keyring so it survives the next shell — strictly
# opt-in, never in CI, and only the env value we already read.
_offer_import_env_api_key(console, pname, env_key, env_var)
return _make_cloud_config(pname, env_key)
if has_litellm:
chosen = _setup_from_key_variables(console)
if chosen is _STOP_SETUP:
return None
if chosen is not None:
return chosen

# 3. Explicit OLLAMA_HOST env var.
if has_litellm and os.environ.get("OLLAMA_HOST"):
Expand Down
37 changes: 16 additions & 21 deletions fluid_build/cli/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1892,34 +1892,29 @@ def _run_main(args, logger: logging.Logger) -> int:
if console and not get_cli_arg(args, "non_interactive", False) and not _picker_ran:
print_welcome_panel(console)

# Check LLM readiness; load saved config or offer inline setup
if not get_cli_arg(args, "non_interactive", False):
# Check LLM readiness; load saved config or offer inline setup. Setup
# cannot see the run's flags, so a run that names its provider
# (--llm-provider, FLUID_LLM_PROVIDER, FLUID_FORGE_AGENT) skips it and
# resolves as a run without a TTY does. The copilot's preflight still
# fails first on a provider this install cannot run (no litellm), before
# anything reads the keyring.
from fluid_build.llm.providers import named_llm_provider

if not get_cli_arg(args, "non_interactive", False) and not named_llm_provider(args):
from fluid_build.cli.ai_setup import run_ai_setup_inline

# An explicit litellm-backed --llm-provider on an install without
# litellm fails here, before the setup flow reads the OS keyring or
# offers to import or prompt for a key it could not use.
if get_cli_arg(args, "llm_provider", None):
from fluid_build.llm.providers import preflight_llm_provider

preflight_llm_provider(args)

# Always go through inline setup — it handles all cases:
# Inline setup handles every run that names no provider:
# 1. Config file exists with key → loads it, sets env vars, returns config
# 2. Keyring has key → loads it, sets env vars, returns config
# 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
args.llm_endpoint = llm_config.endpoint
if llm_config.api_key:
from fluid_build.llm.providers import fill_llm_args_from_setup

# Setup fills only what the operator left unstated
# (--llm-model, --llm-endpoint and their FLUID_LLM_* twins win),
# and its key goes into the session only with its own provider.
if fill_llm_args_from_setup(args, llm_config) and llm_config.api_key:
from fluid_build.cli.ai_setup import set_session_env

set_session_env(llm_config.provider, llm_config.api_key)
Expand Down
Loading
Loading