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
10 changes: 5 additions & 5 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`fluid doctor` reports an installed coding agent as ready.** With `FLUID_LLM_PROVIDER` set to
`claude-code`, `codex`, `cursor` or `kiro`, readiness said "No API key found"; it now checks
that the agent's CLI is on PATH, and says so when it is not. Each CLI keeps its own sign-in.
- **`fluid forge --agent` reports a contract written to a `--target-dir` outside the current
directory.** The receipt and the `forge.contract_written` event came from a scan of the current
directory, two levels deep, so a target outside it, or deeper, got neither: the run wrote the
contract and the stream never said so. The stated target is scanned too, and a file outside the
current directory is reported by its absolute path.
- **Forge asks the Ollama it calls.** With `--llm-endpoint` or `FLUID_LLM_ENDPOINT` set, forge
picked its default Ollama model from `OLLAMA_HOST` or localhost, and so did its model preflight
and `fluid ai test --endpoint`, which failed with "model ... was not returned by the provider"
for a model the endpoint has. All three now ask the stated endpoint. A `--tiered` stage now
calls a stated endpoint too, as the routing model already did; it called the provider's
default address instead.

### Changed

Expand All @@ -66,6 +77,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`claude --help` lists it. Claude Code authentication that lives in a setting (an
`apiKeyHelper`, or Bedrock or Vertex variables in `settings.json`) must come from the shell
environment instead.
- **`OLLAMA_HOST` may name any machine, as in Ollama's own client.** The chat call already dialled
it, but detection, forge's default-model pick and `fluid ai setup` ignored a non-local host with
a warning and asked localhost, so the model came from one daemon and the call went to another.
Every Ollama read now goes through one helper, `fluid_build/llm/ollama_host.py`. A project
`.env` still cannot set `OLLAMA_HOST`. `fluid ai test --endpoint` accepts any Ollama host, over
http or https. A stated endpoint is read the same way (`--llm-endpoint gpu-box` means
`http://gpu-box:11434`) and keeps a `user:password@`, sent as basic auth. `fluid doctor` and
`fluid ai setup` show it masked, and `~/.fluid/ai_config.json` stores a saved address without
it unless `FLUID_ALLOW_PLAINTEXT_AI_SECRETS` is set, as for API keys.
- **With no provider selected, forge stops instead of using gemini.** It used to send
`FLUID_LLM_API_KEY` to Google whatever key it was, and with two provider keys set it asked for
a gemini key. It now fails with `copilot_no_llm_provider` and says why (nothing configured,
several keys, a key that names no provider, or a `fluid ai setup` choice that the command does
not read), in the words `fluid doctor` shows. Pass `--llm-provider`, or set
`FLUID_LLM_PROVIDER` or the key of exactly one provider.
- **`LlmProvider.list_available_models` takes an `endpoint` keyword**, the address the call will
go to, passed only when the operator states one. A provider plugin that overrides the method
with the two-argument signature keeps working until an endpoint is stated.

## [0.28.0] — 2026-11-06

Expand Down
9 changes: 9 additions & 0 deletions fluid_build/_error_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,15 @@ def slug_for(event: str) -> str:
],
"secrets",
),
"copilot_no_llm_provider": (
[
"Pass --llm-provider, for example anthropic, openai, gemini or ollama",
"Or set FLUID_LLM_PROVIDER, or the API key of exactly one provider",
"Or run 'fluid ai setup' to choose and save a provider",
"Run 'fluid doctor' to see what forge would select",
],
None,
),
# Optional-dependency gate (fluid_build/_optional.py). The message already
# carries the exact `pip install` line; these only give the stable slug a
# curated follow-up and a docs anchor.
Expand Down
19 changes: 13 additions & 6 deletions fluid_build/cli/_ai_setup_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Resolves host-module dependencies via the
``module-attribute-access`` indirection pattern so test patches on
``fluid_build.cli.ai_setup.<helper>`` (e.g. ``_save_ai_config``,
``_sanitize_ollama_host``) flow through to this module.
``_normalize_ollama_host``) flow through to this module.
"""

from __future__ import annotations
Expand All @@ -33,8 +33,15 @@ def _host():
return _as


def _sanitize_ollama_host(host: str) -> str:
return _host()._sanitize_ollama_host(host)
def _normalize_ollama_host(host: str) -> str:
return _host()._normalize_ollama_host(host)


def _shown(host: str) -> str:
"""*host* as printed to the terminal: credentials in the address masked."""
from fluid_build.llm.providers import redact_endpoint

return redact_endpoint(host)


def _save_ai_config(*args, **kwargs):
Expand Down Expand Up @@ -70,7 +77,7 @@ def _poll_for_ollama(
char = spinner_chars[tick % len(spinner_chars)]
try:
console.print(
f"[dim]{char} Waiting for Ollama at {host}… "
f"[dim]{char} Waiting for Ollama at {_shown(host)}… "
f"({int(deadline - time.time())}s left, Ctrl-C to give up)[/dim]"
)
except Exception: # noqa: BLE001
Expand Down Expand Up @@ -129,14 +136,14 @@ def _setup_ollama(console: Any) -> Optional[Any]:
LlmConfig = host_mod.LlmConfig

provider = BUILTIN_LLM_PROVIDERS["ollama"]
host = _sanitize_ollama_host(os.environ.get("OLLAMA_HOST", "http://localhost:11434"))
host = _normalize_ollama_host(os.environ.get("OLLAMA_HOST") or "")
os.environ["OLLAMA_HOST"] = host

available_models = _query_ollama_models(host)
if not available_models:
console.print(
"[yellow]Couldn't reach Ollama at "
f"[bold]{host}[/bold] (no models installed or daemon not running)."
f"[bold]{_shown(host)}[/bold] (no models installed or daemon not running)."
"[/yellow]\n"
"[dim]Install: [bold cyan]https://ollama.com[/bold cyan] · "
"Then: [bold]ollama pull llama3.1[/bold][/dim]"
Expand Down
27 changes: 25 additions & 2 deletions fluid_build/cli/_ai_setup_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ def _allow_plaintext_ai_secrets() -> bool:
}


def _without_credentials(url: str) -> str:
"""*url* minus any ``user:password@``, unless plaintext AI secrets are allowed."""
if _allow_plaintext_ai_secrets():
return url
scheme, sep, rest = url.partition("://")
if not sep:
scheme, rest = "", url
# The authority ends at the first "/", "?" or "#" (RFC 3986 section 3.2).
cut = min((i for i in (rest.find(c) for c in "/?#") if i != -1), default=len(rest))
authority, tail = rest[:cut], rest[cut:]
if "@" not in authority:
return url
LOG.warning(
"Credentials in a saved AI address were not written to %s; keep them in the "
"environment (for example OLLAMA_HOST).",
_config_file(),
)
return f"{scheme}{sep}{authority.rpartition('@')[2]}{tail}"


def _read_config_file() -> Optional[dict]:
"""Read + JSON-parse ``~/.fluid/ai_config.json`` (delegates to the leaf).

Expand Down Expand Up @@ -226,10 +246,13 @@ def _save_ai_config(
LOG.warning("Plaintext local AI credential fallback is enabled.")
else:
LOG.debug("Keyring unavailable; sensitive AI value was not persisted.")
# A URL can carry ``user:password@``. Like an API key, it is written to the
# file only behind the plaintext opt-in; otherwise the address is saved
# without it and the credentials stay in the environment (OLLAMA_HOST, ...).
if endpoint:
entry["endpoint"] = endpoint
entry["endpoint"] = _without_credentials(endpoint)
if ollama_host:
entry["ollama_host"] = ollama_host
entry["ollama_host"] = _without_credentials(ollama_host)

providers[provider] = entry
return _write_config_map(active=provider, providers=providers)
Expand Down
16 changes: 9 additions & 7 deletions fluid_build/cli/_ai_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ def detect_ollama_available(*args, **kwargs):
return _host().detect_ollama_available(*args, **kwargs)


def _sanitize_ollama_host(*args, **kwargs):
return _host()._sanitize_ollama_host(*args, **kwargs)
def _normalize_ollama_host(*args, **kwargs):
return _host()._normalize_ollama_host(*args, **kwargs)


def _resolve_cloud_api_key(*args, **kwargs):
Expand Down Expand Up @@ -193,10 +193,9 @@ def _validate_ai_test_endpoint(provider_name: str, endpoint: str) -> Optional[st
return "AI test endpoint URLs must not embed credentials."
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return "AI test endpoint must be an absolute HTTP(S) URL."
host = (parsed.hostname or "").lower()
if provider_name == "ollama":
if parsed.scheme != "http" or host not in {"localhost", "127.0.0.1", "::1"}:
return "Ollama AI test endpoints must stay on localhost."
# Any host, over http or https: the same as every other Ollama read
# (fluid_build/llm/ollama_host.py). Ollama sends no API key.
return None
if parsed.scheme != "https":
return "Cloud AI test endpoints must use HTTPS to avoid sending API keys over plaintext."
Expand Down Expand Up @@ -247,7 +246,7 @@ def _build_for_provider(
if pname == "ollama":
host = saved_for_provider.get("ollama_host")
if host and not os.environ.get("OLLAMA_HOST"):
os.environ["OLLAMA_HOST"] = _sanitize_ollama_host(str(host))
os.environ["OLLAMA_HOST"] = _normalize_ollama_host(str(host))
# Only an operator-supplied endpoint is validated; with none, the
# call goes through litellm, which owns the address.
if endpoint_override:
Expand Down Expand Up @@ -354,7 +353,10 @@ def _check_ai_test_model_availability(
provider: Any, config: LlmConfig
) -> tuple[str, Optional[list[str]]]:
try:
available = provider.list_available_models(config.api_key, dict(os.environ))
# The list comes from where the test call goes: the --endpoint, if any.
# The keyword goes only with an endpoint (two-argument plugins keep working).
stated = {"endpoint": config.endpoint} if config.endpoint else {}
available = provider.list_available_models(config.api_key, dict(os.environ), **stated)
except httpx.HTTPStatusError as exc:
raise CopilotGenerationError(
"ai_test_model_preflight_failed",
Expand Down
32 changes: 14 additions & 18 deletions fluid_build/cli/ai_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,22 +137,16 @@ def __getattr__(name: str):

LOG = logging.getLogger("fluid.cli.ai_setup")

# SSRF guard: restrict Ollama host to localhost addresses only.
_LOCALHOST_PREFIXES = (
"http://localhost",
"http://127.0.0.1",
"http://[::1]",
"http://0.0.0.0",
)

def _normalize_ollama_host(host: str) -> str:
"""*host* as a URL to dial, or the default local address when blank.

Any machine is allowed, the same as for every other Ollama read
(``fluid_build/llm/ollama_host.py``).
"""
from fluid_build.llm.ollama_host import ollama_daemon_url

def _sanitize_ollama_host(host: str) -> str:
"""Return *host* if it points to localhost, otherwise fall back to the default."""
clean = (host or "http://localhost:11434").rstrip("/")
if not any(clean.lower().startswith(p) for p in _LOCALHOST_PREFIXES):
LOG.warning("OLLAMA_HOST points to a non-localhost address (%s), ignoring.", clean)
return "http://localhost:11434"
return clean
return ollama_daemon_url({}, host)


# Keyring key prefix used when persisting API keys.
Expand Down Expand Up @@ -1388,10 +1382,12 @@ def run_ai_setup_inline(console: Any) -> Optional[LlmConfig]:
LOG.info("Inline AI setup: loaded coding agent %s from saved config", pname)
return config
if pname == "ollama":
ollama_host = _sanitize_ollama_host(
saved.get("ollama_host", "http://localhost:11434")
)
os.environ["OLLAMA_HOST"] = ollama_host
# The environment wins over the saved address: it may hold the
# ``user:password@`` that ai_config.json leaves out.
if not os.environ.get("OLLAMA_HOST"):
os.environ["OLLAMA_HOST"] = _normalize_ollama_host(
saved.get("ollama_host") or ""
)
config = _make_ollama_config(model=model)
if console and RICH_AVAILABLE:
console.print(f"[dim]Using Ollama ({config.model}).[/dim]")
Expand Down
5 changes: 4 additions & 1 deletion fluid_build/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1412,7 +1412,10 @@ def _print_copilot_readiness(readiness: LlmReadinessCheck, verbose: bool = False
status_text = "✅ Ready" if readiness.ready else "⚠️ Setup needed"
auth_text = "Configured" if readiness.auth_available else "Missing"
# No endpoint of ours is not "not configured": litellm picks the provider's own.
endpoint_text = readiness.endpoint or (
# An endpoint can carry ``user:password@``, so it is shown masked.
from fluid_build.llm.providers import redact_endpoint

endpoint_text = redact_endpoint(readiness.endpoint) or (
"Provider default" if readiness.provider else "Not configured"
)

Expand Down
31 changes: 24 additions & 7 deletions fluid_build/cli/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from fluid_build.cli.artifact_envelope import dump_json_with_envelope
from fluid_build.cli.artifact_paths import product_forge_receipt_path
from fluid_build.cli.artifact_receipts import ReceiptBuilder
from fluid_build.cli.artifact_scan import diff_snapshots, snapshot_workspace
from fluid_build.cli.artifact_scan import ArtifactSnapshot, diff_snapshots, snapshot_workspace
from fluid_build.cli.console import cprint, cprint_literal
from fluid_build.cli.console import error as console_error
from fluid_build.cli.forge_banner import print_v2_banner
Expand Down Expand Up @@ -1657,12 +1657,12 @@ def _run_main(args, logger: logging.Logger) -> int:
return _run_watch(args, logger)

# --- Snapshot for the forge receipt ---
# Scan cwd before any mode runs so the diff catches every file the
# mode handler wrote. find_workspace_root is cheap and localised;
# if this isn't a workspace yet, we fall back to cwd and still
# produce a receipt.
# Scan cwd (and an explicit --target-dir) before any mode runs so the
# diff catches every file the mode handler wrote. find_workspace_root
# is cheap and localised; if this isn't a workspace yet, we fall back
# to cwd and still produce a receipt.
scan_root = Path.cwd()
before_snapshot = snapshot_workspace(scan_root)
before_snapshot = _forge_snapshot(scan_root, args)
import fluid_build.cli.forge as _self

_self.reset_token_usage()
Expand Down Expand Up @@ -2049,6 +2049,23 @@ def load_context(
_AGENT_EVENT_ACTIONS = {"create": "created", "update": "modified"}


def _forge_snapshot(scan_root: Path, args: Any) -> ArtifactSnapshot:
"""Snapshot *scan_root*, plus the explicit ``--target-dir`` when one is given.

The cwd scan reaches contracts two directory levels down. A target outside
cwd, or deeper than that, needs its own scan, or the receipt and the
``--agent`` ``forge.contract_written`` event miss the contract written
there. A file under both roots is keyed by its resolved path, so it is
counted once; one outside cwd is reported by its absolute path.
"""
snapshot = snapshot_workspace(scan_root)
target_dir = get_cli_arg(args, "target_dir", None)
if isinstance(target_dir, (str, os.PathLike)) and str(target_dir):
# ``~`` as the writer reads it (``forge_modes`` expands the target too).
snapshot.files.update(snapshot_workspace(Path(target_dir).expanduser()).files)
return snapshot


def _write_forge_receipt(
*,
flow: str,
Expand All @@ -2070,7 +2087,7 @@ def _write_forge_receipt(
recently modified one.
"""
try:
after_snapshot = snapshot_workspace(scan_root)
after_snapshot = _forge_snapshot(scan_root, args)
entries = diff_snapshots(before_snapshot, after_snapshot)
# Ignore no-op rows — the receipt only records what changed.
changed = [e for e in entries if e.action != "unchanged"]
Expand Down
8 changes: 6 additions & 2 deletions fluid_build/copilot/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,12 @@ def resolve_model(self, session: StageSession) -> Optional[LlmConfig]:
or get_catalog_tier_model(provider_name, self.tier)
or session.llm_config.model
)
provider = BUILTIN_LLM_PROVIDERS[provider_name]
endpoint = provider.default_endpoint(tier_model, dict(os.environ))
if session.llm_config.endpoint_explicit:
# A stated endpoint is where every call goes, as in ``LlmConfig.for_routing``.
endpoint = session.llm_config.endpoint
else:
provider = BUILTIN_LLM_PROVIDERS[provider_name]
endpoint = provider.default_endpoint(tier_model, dict(os.environ))
return dataclasses.replace(session.llm_config, model=tier_model, endpoint=endpoint)

def cache_namespace(self) -> str:
Expand Down
Loading
Loading