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

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

43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
granted. The contract-tests local action grants its outputs the same way. The DuckDB
acquisition runner's landing got this fix in 0.19.0 (#699). Found on the demo lab on 0.18.2,
still in 0.28.0.
- **Local Ollama works through every automatic path, and `fluid ai test` probes the provider it
names.** With no endpoint set, `LlmConfig.endpoint` carried a telemetry label,
`litellm://<provider>/<model>`. Ollama calls sent it to litellm as `api_base`, so no call
reached the daemon, and `fluid ai test` posted to it and failed with "AI test network error
... (UnsupportedProtocol)" for every litellm provider. The endpoint now holds only a real
address: the one `--llm-endpoint` or `FLUID_LLM_ENDPOINT` states, else Ollama's `OLLAMA_HOST`,
else none (litellm's own default). A bare `OLLAMA_HOST=myhost` means `http://myhost:11434`, as
in Ollama's own client. The routing model's calls keep a stated endpoint. `fluid ai test`
sends its probe through the call `fluid forge` makes and exits 4 when the provider says the
model does not exist. `fluid doctor` shows "Provider default" instead of the label.
- **`fluid forge --agent` with `--llm-provider`, `--llm-model`, `--llm-endpoint` or `--context`
runs AI mode.** It dropped those flags, wrote a TODO placeholder contract and reported
`exit_code 0`, which an IDE agent reads as success. It now authors with forge's AI,
non-interactively, and a failed generation exits non-zero. AI-mode runs now emit
`forge.contract_written`, which the stream promised but only blank mode sent. `--agent` with
no AI flag still writes the blank contract.
- **A Claude Code login problem is reported as one.** `claude -p` reports a failure on stdout,
as a JSON result with `is_error: true`, and leaves stderr empty, so forge showed
"claude-code exited with code 1:" and no reason. Forge now reads that result: a login problem
says the agent "is installed but not authenticated" with the sign-in hint, any other failure
shows the agent's own message, and `is_error: true` is a failure even at exit code 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.

### Changed

- **A non-interactive run with no LLM configured authors with an installed coding agent.** When
nothing names one (no `--llm-provider`, `--llm-endpoint` or `--llm-model`, no `FLUID_LLM_*`,
`OLLAMA_HOST` or provider key variable, no keyring key, no `fluid ai setup` provider) and stdin
is not a terminal or `--non-interactive` is set, forge uses a running local Ollama first, so
local data stays local, else the first of `claude`, `codex`, `cursor-agent` and `kiro-cli` on
PATH, and logs which one and how to override it. It works on the base install: the agents need
no litellm. Interactive runs still only offer the agent, and `--agent-loop` never picks one
(the agents have no tool use).
- **Forge runs `claude -p` apart from your own Claude Code setup.** Each call replaces Claude
Code's agent system prompt with forge's (`--system-prompt`) and turns off tools, MCP servers,
your settings (hooks, plugins, `CLAUDE.md`) and session persistence (`--tools ""`,
`--strict-mcp-config`, `--setting-sources=`, `--no-session-persistence`), and drops
`CLAUDECODE`, as Anthropic's Agent SDK does. Each flag is passed only when the installed
`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.

## [0.28.0] — 2026-11-06

Expand Down
76 changes: 62 additions & 14 deletions fluid_build/cli/_ai_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,6 @@ def _validate_ai_test_endpoint(provider_name: str, endpoint: str) -> Optional[st
parsed = urlsplit(raw)
if parsed.username or parsed.password:
return "AI test endpoint URLs must not embed credentials."
# litellm-routed providers (the default) use a ``litellm://`` sentinel
# that's not a real URL — litellm owns auth + transport, so there's no
# plaintext-credential risk to validate against. Accept the sentinel.
if parsed.scheme == "litellm":
return None
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()
Expand Down Expand Up @@ -253,10 +248,13 @@ def _build_for_provider(
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))
# Only an operator-supplied endpoint is validated; with none, the
# call goes through litellm, which owns the address.
if endpoint_override:
endpoint_error = _validate_ai_test_endpoint(pname, endpoint_override)
if endpoint_error:
return None, endpoint_error
endpoint = endpoint_override or provider.default_endpoint(model, dict(os.environ))
endpoint_error = _validate_ai_test_endpoint(pname, endpoint)
if endpoint_error:
return None, endpoint_error
return (
LlmConfig(
provider=pname,
Expand All @@ -272,9 +270,10 @@ 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)
endpoint_error = _validate_ai_test_endpoint(pname, config.endpoint)
if endpoint_error:
return None, endpoint_error
if config.endpoint:
endpoint_error = _validate_ai_test_endpoint(pname, config.endpoint)
if endpoint_error:
return None, endpoint_error
return (
config,
None,
Expand Down Expand Up @@ -391,8 +390,48 @@ def _check_ai_test_model_availability(
return "available", available


def _is_litellm_backed(provider: Any) -> bool:
from fluid_build.llm.litellm_backend import LiteLLMProvider

return isinstance(provider, LiteLLMProvider)


def _run_litellm_smoke_call(provider: Any, config: LlmConfig) -> tuple[str, dict, int]:
"""The diagnostic call for a litellm-backed provider: the call forge itself makes.

litellm owns the address, auth and transport, so there is no URL to POST to.
"""
from fluid_build.llm.providers import get_cumulative_token_usage

cap = (
_AI_TEST_GEMINI_OUTPUT_TOKENS
if config.provider == "gemini"
else _AI_TEST_DEFAULT_OUTPUT_TOKENS
)
before = get_cumulative_token_usage()
started = time.perf_counter()
text = provider.invoke_blocking(
config,
_AI_TEST_SYSTEM_PROMPT,
_AI_TEST_USER_PROMPT,
extra_payload={"max_tokens": cap, "timeout": config.timeout_seconds},
)
latency_ms = int((time.perf_counter() - started) * 1000)
after = get_cumulative_token_usage()
usage = {key: after[key] - before[key] for key in after}
if "FLUID_OK" not in (text or ""):
raise CopilotGenerationError(
"ai_test_unexpected_response",
f"AI test response from {config.provider} was not the expected diagnostic token.",
suggestions=["Try again, or choose a different model with `fluid ai setup`."],
)
return "FLUID_OK", usage, latency_ms


def _run_ai_smoke_call(provider: Any, config: LlmConfig) -> tuple[str, dict, int]:
"""Issue the diagnostic call. Returns (text, usage, latency_ms)."""
if _is_litellm_backed(provider):
return _run_litellm_smoke_call(provider, config)
headers, payload = _with_freeform_ai_test_payload(provider, config)
started = time.perf_counter()
try:
Expand Down Expand Up @@ -454,6 +493,12 @@ def _classify_ai_test_error(error_code: str, status_code: Optional[int]) -> int:

0 OK / 2 config / 3 auth / 4 resource / 5 network. See AGENTS.md for the contract.
"""
if error_code in {"copilot_llm_auth_failed", "copilot_llm_permission_denied"}:
return _AI_TEST_EXIT_AUTH
if error_code == "copilot_litellm_request_failed":
# A provider "not found" (a model it does not have) is a resource problem;
# timeouts, connection errors and the rest stay network.
return _AI_TEST_EXIT_RESOURCE if status_code == 404 else _AI_TEST_EXIT_NETWORK
if error_code in {"ai_test_request_failed", "ai_test_model_preflight_failed"}:
if status_code is None:
return _AI_TEST_EXIT_NETWORK
Expand All @@ -471,7 +516,10 @@ def _http_status_from_cause(exc: CopilotGenerationError) -> Optional[int]:
cause = exc.__cause__
if isinstance(cause, httpx.HTTPStatusError):
return cause.response.status_code
return None
# litellm's exceptions (the original error, chained by ``_translate_litellm_exception``)
# carry the provider's HTTP status.
status = getattr(cause, "status_code", None)
return status if isinstance(status, int) else None


def _new_ai_test_report(
Expand Down Expand Up @@ -653,7 +701,7 @@ def run_ai_test(console: Any, args: Any) -> tuple[int, dict]:
table.add_column("Result", style="green")
table.add_row("Provider", label)
table.add_row("Model", _safe_ai_test_display(config.model))
table.add_row("Endpoint", _safe_ai_test_display(endpoint))
table.add_row("Endpoint", _safe_ai_test_display(endpoint) or "provider default")
table.add_row(
"Model availability",
(
Expand All @@ -673,7 +721,7 @@ def run_ai_test(console: Any, args: Any) -> tuple[int, dict]:
from fluid_build.cli.console import cprint

cprint(f"AI Provider Test: ready ({label}, {_safe_ai_test_display(config.model)})")
cprint(f" Endpoint: {_safe_ai_test_display(endpoint)}")
cprint(f" Endpoint: {_safe_ai_test_display(endpoint) or 'provider default'}")
cprint(f" Model availability: {availability}")
cprint(f" Live call: {text}")
cprint(f" Latency: {latency_text}")
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 @@ -1411,7 +1411,10 @@ def _print_copilot_readiness(readiness: LlmReadinessCheck, verbose: bool = False
"""Render the copilot readiness summary without leaking secrets."""
status_text = "✅ Ready" if readiness.ready else "⚠️ Setup needed"
auth_text = "Configured" if readiness.auth_available else "Missing"
endpoint_text = readiness.endpoint or "Not configured"
# No endpoint of ours is not "not configured": litellm picks the provider's own.
endpoint_text = readiness.endpoint or (
"Provider default" if readiness.provider else "Not configured"
)

if RICH_AVAILABLE:
console = Console()
Expand Down
Loading
Loading