diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6e4a9add9..8295f197a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/framework-r-d/phlex-dev:latest +FROM ghcr.io/framework-r-d/phlex-dev:2026-06-24 # Validate Python site-packages symlink. # Invoke bash explicitly: the script uses bash features (pipefail, arrays, @@ -88,15 +88,26 @@ if [ "$(dpkg-divert --truename /usr/bin/man)" = "/usr/bin/man.REAL" ]; then fi INSTALL_MAN_TOOLS -# Install podman client and socat inside the container to support nested +# Install dev utils # container communication and provide a 'docker' command alias. -RUN bash <<'INSTALL_PODMAN_CLIENT' +RUN bash <<'INSTALL_DEV_UTILS' set -euo pipefail apt-get update -apt-get install -y --no-install-recommends ssh podman socat +apt-get install -y --no-install-recommends jq podman ssh socat tree yq +apt purge nodejs npm -y && apt autoremove -y || true +apt-get install -y curl --no-install-recommends --autoremove +curl -fsSL https://deb.nodesource.com/setup_26.x | bash - +apt-get install -y nodejs apt-get clean rm -rf /var/lib/apt/lists/* -INSTALL_PODMAN_CLIENT +INSTALL_DEV_UTILS + +RUN bash <<'INSTALL_NPM_PACKAGES' +set -euo pipefail +npm -g update +npm config set allow-scripts=@kilocode/cli --location=user +npm install -g @kilocode/cli +INSTALL_NPM_PACKAGES # Wire up the root user's .bashrc to source the Spack environment via # /entrypoint.sh on every interactive shell. diff --git a/.devcontainer/codespace.code-workspace b/.devcontainer/codespace.code-workspace index c208ed9c2..b7b289bf1 100644 --- a/.devcontainer/codespace.code-workspace +++ b/.devcontainer/codespace.code-workspace @@ -114,7 +114,6 @@ "extensions": { "recommendations": [ "charliermarsh.ruff", - "github.copilot-chat", "github.vscode-github-actions", "github.vscode-pull-request-github", "ms-vscode.cmake-tools", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c60f4acb5..91b3276d7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,47 +1,17 @@ { "name": "Phlex CI Dev Container", - "build": { - "dockerfile": "Dockerfile" - }, - "runArgs": [], + "dockerComposeFile": "docker-compose.yml", + "service": "phlex-dev", "workspaceFolder": "/workspaces/phlex", "remoteUser": "root", "remoteEnv": { - // KILO_CONFIG_CONTENT_DOCKER rewrites 127.0.0.1:PORT to - // host.docker.internal:RELAY_PORT for the socat relay used when - // headroom runs as a local proxy. Passed through here so that - // post-create.sh can wire it into the container's .bashrc - // conditionally: if non-empty it sets KILO_CONFIG_CONTENT to this - // value; otherwise KILO_CONFIG_CONTENT is left unset and Kilo falls - // back to ~/.config/kilo/kilo.jsonc (bind-mounted from the host). - "KILO_CONFIG_CONTENT_DOCKER": "${localEnv:KILO_CONFIG_CONTENT_DOCKER}", "KILO_API_KEY": "${localEnv:HEADROOM_UPSTREAM_KEY}" }, - "containerEnv": { - "CMAKE_GENERATOR": "Ninja", - "GH_CONFIG_DIR": "/root/.config/gh", - "DOCKER_HOST": "unix:///tmp/podman.sock", - "CONTAINER_HOST": "unix:///tmp/podman.sock", - "GNUPGHOME": "/root/.gnupg" - }, - "mounts": [ - "source=${localWorkspaceFolder}/../phlex-coding-guidelines,target=/workspaces/phlex-coding-guidelines,type=bind", - "source=${localWorkspaceFolder}/../phlex-design,target=/workspaces/phlex-design,type=bind", - "source=${localWorkspaceFolder}/../phlex-examples,target=/workspaces/phlex-examples,type=bind", - "source=${localWorkspaceFolder}/../phlex-spack-recipes,target=/workspaces/phlex-spack-recipes,type=bind", - "source=${localEnv:HOME}/.aws,target=/root/.aws,type=bind", - "source=${localEnv:HOME}/.config/gh,target=/root/.config/gh,type=bind,readonly", - "source=${localEnv:HOME}/.config/kilo,target=/root/.config/kilo,type=bind", - "source=${localEnv:HOME}/.gnupg,target=/root/.gnupg,type=bind", - "source=${localEnv:HOME}/.kiro,target=/root/.kiro,type=bind", - "source=phlex-kilo-data,target=/root/.local/share/kilo,type=volume", - "source=${localEnv:HOME}/.podman-proxy/podman.sock,target=/tmp/podman.sock,type=bind", - "source=${localEnv:HOME}/.vscode-remote-user-data,target=/root/.vscode-server-insiders/data/User,type=bind" - ], "initializeCommand": "bash .devcontainer/ensure-repos.sh", "onCreateCommand": "bash .devcontainer/setup-repos.sh /workspaces", "postCreateCommand": "bash -lc 'bash .devcontainer/post-create.sh'", "customizations": { + "vscode": { "settings": { "terminal.integrated.defaultProfile.linux": "bash", @@ -88,7 +58,6 @@ "**/.venv/**": true, "**/py_virtual_env/**": true }, - "github.copilot-chat.usePreReleaseVersion": false, "kilocode.new.extraCaCerts": "" }, "extensions": [ @@ -99,7 +68,6 @@ "donjayamanne.githistory", "dotjoshjohnson.xml", "eamodio.gitlens", - "github.copilot-chat", "github.vscode-github-actions", "github.vscode-pull-request-github", "jebbs.plantuml", @@ -114,13 +82,11 @@ "ms-python.vscode-pylance", "ms-python.vscode-python-envs", "ms-vscode.cmake-tools", - "ms-vscode.cpptools", "ms-vscode.cpptools-extension-pack", "ms-vscode.cpptools-themes", "ms-vscode.hexeditor", "ms-vscode.live-server", "ms-vscode.makefile-tools", - "ms-vscode.vscode-websearchforcopilot", "redhat.vscode-yaml", "shd101wyy.markdown-preview-enhanced", "swyddfa.esbonio", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..49ac938b8 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.8" + +services: + phlex-dev: + build: + context: . + dockerfile: Dockerfile + volumes: + - ..:/workspaces/phlex + - ../../phlex-coding-guidelines:/workspaces/phlex-coding-guidelines + - ../../phlex-design:/workspaces/phlex-design + - ../../phlex-examples:/workspaces/phlex-examples + - ../../phlex-spack-recipes:/workspaces/phlex-spack-recipes + - ${HOME}/.config/gh:/root/.config/gh:ro + - ${HOME}/.config/kilo:/root/.config/kilo + - ${HOME}/.gnupg:/root/.gnupg + - ${HOME}/.podman-proxy/podman.sock:/tmp/podman.sock + - ${HOME}/.local/share/kilo:/root/.local/share/kilo:ro + - phlex-vscode-user-data:/root/.vscode-server-insiders/data/User + environment: + - CMAKE_GENERATOR=Ninja + - GH_CONFIG_DIR=/root/.config/gh + - DOCKER_HOST=unix:///tmp/podman.sock + - CONTAINER_HOST=unix:///tmp/podman.sock + - GNUPGHOME=/root/.gnupg + user: root + tty: true + stdin_open: true + +volumes: + phlex-vscode-user-data: diff --git a/.devcontainer/ensure-repos.sh b/.devcontainer/ensure-repos.sh index 9303c9ddf..ef8ea76aa 100755 --- a/.devcontainer/ensure-repos.sh +++ b/.devcontainer/ensure-repos.sh @@ -150,44 +150,60 @@ s.bind('${PROXY_SOCKET}') " 2>/dev/null || touch "${PROXY_SOCKET}" fi -# --- Headroom Proxy Relay for Devcontainer --- +# --- Headroom Proxy Relays for Devcontainer --- # -# The headroom proxy is an SSH-tunnelled port bound only to 127.0.0.1 on this -# host. Rootless Podman uses pasta for container networking, so containers -# reach the host via host.docker.internal (169.254.1.2) rather than via a -# bridge interface. However, pasta maps host.docker.internal to the host's -# loopback only for ports that are actually listening on all interfaces -- -# headroom's port is bound to 127.0.0.1 only and is therefore unreachable. +# The headroom proxy was split into TWO SSH-tunnelled ports, each bound only to +# 127.0.0.1 on this host: +# HEADROOM_AZURE_PORT (9797) : optimized proxy, Kilo provider fnal-azure +# HEADROOM_OW_PORT (9798) : passthrough proxy, Kilo provider fnal-ow +# Rootless Podman uses pasta for container networking, so containers reach the +# host via host.docker.internal (169.254.1.2) rather than via a bridge +# interface. However, pasta maps host.docker.internal to the host's loopback +# only for ports that are actually listening on all interfaces -- headroom's +# ports are bound to 127.0.0.1 only and are therefore unreachable. # -# We relay headroom onto a different port on 0.0.0.0 so that containers can -# reach it via host.docker.internal:$HEADROOM_RELAY_PORT. A different port is -# required because 0.0.0.0:$HEADROOM_PORT would conflict with the existing -# 127.0.0.1:$HEADROOM_PORT listener. KILO_CONFIG_CONTENT_DOCKER is passed into the -# devcontainer and post-create.sh wires it into /root/.bashrc to point at -# host.docker.internal:$HEADROOM_RELAY_PORT. - -HEADROOM_PORT="${HEADROOM_PORT:-9797}" -HEADROOM_RELAY_PORT=$(( HEADROOM_PORT + 10000 )) -HEADROOM_LOCAL="127.0.0.1:${HEADROOM_PORT}" - -if ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${HEADROOM_PORT}"; then - start_socat_relay \ - "Headroom proxy" \ - "socat TCP-LISTEN:${HEADROOM_RELAY_PORT}" \ - "TCP-LISTEN:${HEADROOM_RELAY_PORT},fork,reuseaddr" \ - "TCP:${HEADROOM_LOCAL}" \ - "/tmp/socat-headroom.log" \ - "ss -tlnp 2>/dev/null | grep -q ':${HEADROOM_RELAY_PORT} '" || true -else - echo "WARNING: headroom proxy not detected at ${HEADROOM_LOCAL}; skipping relay" >&2 - echo " Ensure the SSH tunnel is active (headroom running on your laptop)" >&2 -fi +# We relay EACH headroom port onto a different port on 0.0.0.0 so that +# containers can reach them via host.docker.internal:. A different +# port is required because 0.0.0.0: would conflict with the existing +# 127.0.0.1: listener. KILO_CONFIG_CONTENT_DOCKER (built in the shell rc) +# rewrites both loopback ports to their host.docker.internal relay ports and is +# wired into /root/.bashrc by post-create.sh. + +HEADROOM_AZURE_PORT="${HEADROOM_AZURE_PORT:-9797}" +HEADROOM_OW_PORT="${HEADROOM_OW_PORT:-9798}" + +# relay_headroom_port ROLE PROXY_PORT +# Relay 127.0.0.1:PROXY_PORT to 0.0.0.0:(PROXY_PORT+10000) if the proxy is +# listening; otherwise warn and skip. Each role gets an independent relay +# and log file so the two never collide. +relay_headroom_port() { + local role="$1" + local proxy_port="$2" + local relay_port=$(( proxy_port + 10000 )) + local local_addr="127.0.0.1:${proxy_port}" + + if ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${proxy_port}"; then + start_socat_relay \ + "Headroom ${role} proxy" \ + "socat TCP-LISTEN:${relay_port}" \ + "TCP-LISTEN:${relay_port},fork,reuseaddr" \ + "TCP:${local_addr}" \ + "/tmp/socat-headroom-${role}.log" \ + "ss -tlnp 2>/dev/null | grep -q ':${relay_port} '" || true + else + echo "WARNING: headroom ${role} proxy not detected at ${local_addr}; skipping relay" >&2 + echo " Ensure the SSH tunnel is active (headroom-${role} running on your laptop)" >&2 + fi +} + +relay_headroom_port azure "${HEADROOM_AZURE_PORT}" +relay_headroom_port ow "${HEADROOM_OW_PORT}" # Ensure remaining source bind mount points exist. -ensure_bind_dir "$HOME/.aws" ensure_bind_dir "$HOME/.config/"{gh,kilo} -ensure_bind_dir "$HOME/.gnupg" -ensure_bind_dir "$HOME/.kiro" -ensure_bind_dir "$HOME/.vscode-remote-user-data" +ensure_bind_dir -m 0700 "$HOME/.gnupg" +ensure_bind_dir -m 0700 "$HOME/.vscode-remote-user-data" +ensure_bind_dir -m 0700 "$HOME/.local/share/kilo" +ensure_bind_dir -m 0700 "$HOME/.phlex-devcontainer-tmp" echo "SUCCESS: .devcontainer/ensure-repos.sh completed successfully" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f7f35673f..75fecde39 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -17,15 +17,6 @@ EOF # installation on every rebuild. rm -f /root/.vscode-server-insiders/data/Machine/.installExtensionsMarker -# Set KILO_CONFIG_CONTENT for interactive shells. When KILO_CONFIG_CONTENT_DOCKER -# is non-empty (headroom local proxy via socat relay), use it so that the baseURL -# points at host.docker.internal rather than 127.0.0.1. Otherwise leave -# KILO_CONFIG_CONTENT unset so Kilo falls back to ~/.config/kilo/kilo.jsonc, -# which is bind-mounted from the host and may point at an external provider. -if [ -n "${KILO_CONFIG_CONTENT_DOCKER:-}" ]; then - printf 'export KILO_CONFIG_CONTENT=%q\n' "${KILO_CONFIG_CONTENT_DOCKER}" >> /root/.bashrc -fi - # Seed the Kilo Code auth token into the container-private data volume. # The volume is not shared with the host to avoid SQLite conflicts between # the Remote-SSH and devcontainer Kilo Code instances. The API key is @@ -39,10 +30,17 @@ import json import os from pathlib import Path +# The single fnal-litellm provider was split into fnal-azure (optimized) and +# fnal-ow (passthrough); both share the same upstream gateway key. Seed both +# provider keys so Kilo resolves whichever provider a model is routed through. +key = os.environ["KILO_API_KEY"] p = Path("/root/.local/share/kilo/auth.json") p.write_text( json.dumps( - {"fnal-litellm": {"type": "api", "key": os.environ["KILO_API_KEY"]}}, + { + "fnal-azure": {"type": "api", "key": key}, + "fnal-ow": {"type": "api", "key": key}, + }, indent=2, ) + "\n", @@ -100,5 +98,32 @@ elif command -v pre-commit >/dev/null 2>&1; then pre-commit install || true fi -# Install kiro-cli and set up shell integrations. -curl -fsSL https://cli.kiro.dev/install | bash +# Configure Kilo to use host.docker.internal for headroom proxy ports. +# The headroom SSH tunnels bind to 127.0.0.1 only, but rootless Podman's +# pasta networking only routes host.docker.internal to ports listening on +# 0.0.0.0. We use socat relays (set up in ensure-repos.sh) to forward +# 127.0.0.1:9797 -> 0.0.0.0:19797 and 127.0.0.1:9798 -> 0.0.0.0:19798. +# This code rewrites Kilo's baseURL from 127.0.0.1: to +# host.docker.internal: for the relays to work. +cat >> /root/.bashrc <<'EOF' + +# Unset any previous KILO_CONFIG_CONTENT to ensure a clean slate. +unset KILO_CONFIG_CONTENT + +# If the headroom proxy ports (9797, 9798) are active via socat relays, +# Kilo needs a modified config with baseURL pointing to host.docker.internal. +# Parse 'kilo debug config', rewrite baseURLs, and export as KILO_CONFIG_CONTENT. +if command -v kilo >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + # Only rewrite if we have a working Kilo config to work with. + if KILO_CONFIG_CONTENT="$(perl -pe 's{("(?:[^"\\]|\\.)*"|'"'"'(?:[^'"'"'\\]|\\.)*'"'"')|//.*}{$1 // ""}ge' "${HOME}/.config/kilo/kilo.jsonc" | jq -c '.provider |= with_entries( + .value.options.baseURL = + (if .value.options.baseURL == null then null + elif (.value.options.baseURL | test("^https?://127\\.0\\.0\\.1:")) + then (.value.options.baseURL | capture("https?://127\\.0\\.0\\.1:(?[0-9]+)(?.*)") | "http://host.docker.internal:" + ((.port | tonumber + 10000) | tostring) + .rest) + else .value.options.baseURL + end) + )')"; then + export KILO_CONFIG_CONTENT + fi +fi +EOF diff --git a/scripts/git-ai-commit b/scripts/git-ai-commit index 99cea172a..a07dfb653 100755 --- a/scripts/git-ai-commit +++ b/scripts/git-ai-commit @@ -77,11 +77,12 @@ Environment variables GIT_AI_COMMIT_KILO_API kilo backend base URL (default: https://litellm.fnal.gov/v1) GIT_AI_COMMIT_KILO_MODEL kilo backend default model (default: azure/claude-haiku-4-5); setting this env var suppresses auto-escalation - GIT_AI_COMMIT_KILO_PROVIDER kilo auth.json provider key (default: fnal-litellm) + GIT_AI_COMMIT_KILO_PROVIDER kilo auth.json provider key (default: tries + fnal-azure, then fnal-ow, then legacy fnal-litellm) Token resolution (kilo backend): 1. GIT_AI_COMMIT_TOKEN - 2. ~/.local/share/kilo/auth.json (fnal-litellm key) + 2. ~/.local/share/kilo/auth.json (fnal-azure / fnal-ow key) 3. GITHUB_TOKEN / GH_TOKEN 4. `gh auth token` 5. ~/.config/gh/hosts.yml @@ -128,7 +129,18 @@ _DEFAULT_MODEL_KILO = _KILO_MODEL_ENV or "azure/claude-haiku-4-5" # True when the user has explicitly pinned a kilo model via the environment, # which suppresses auto-escalation even if the diff is large. _KILO_MODEL_PINNED_BY_ENV = bool(_KILO_MODEL_ENV) -_DEFAULT_KILO_PROVIDER = os.environ.get("GIT_AI_COMMIT_KILO_PROVIDER", "fnal-litellm") +# auth.json provider key(s) that hold the FNAL gateway API token. The former +# single "fnal-litellm" provider was split into "fnal-azure" and "fnal-ow", +# which share the same upstream key; "fnal-litellm" is retired. When the user +# does not pin GIT_AI_COMMIT_KILO_PROVIDER, try the new keys in order (and the +# retired name last, for any un-migrated auth.json). git-ai-commit talks to +# the LiteLLM gateway directly, so the provider key only selects the auth +# token — model names remain bare (e.g. "azure/...", "qwen/..."). +_KILO_PROVIDER_ENV = os.environ.get("GIT_AI_COMMIT_KILO_PROVIDER", "").strip() +_DEFAULT_KILO_PROVIDER = _KILO_PROVIDER_ENV or "fnal-azure" +_KILO_PROVIDER_FALLBACKS = ( + [_KILO_PROVIDER_ENV] if _KILO_PROVIDER_ENV else ["fnal-azure", "fnal-ow", "fnal-litellm"] +) _DEFAULT_MODEL_OTHER = "gpt-4o" _MAX_DIFF_CHARS = 60_000 @@ -372,8 +384,11 @@ def _get_instructions(root: Path) -> str: def _kilo_auth_token() -> str | None: """Read the kilo provider API key from kilo's auth.json, if present. - The provider name is taken from GIT_AI_COMMIT_KILO_PROVIDER (default: - "fnal-litellm"), matching the key used in ~/.config/kilo/kilo.jsonc. + The provider name(s) come from GIT_AI_COMMIT_KILO_PROVIDER when set; + otherwise the split FNAL providers are tried in order + ("fnal-azure", "fnal-ow", then the retired "fnal-litellm"). These share + the same upstream gateway token, so the first present key wins. The names + match the provider keys in ~/.config/kilo/kilo.jsonc. """ if not _KILO_AUTH_JSON.exists(): return None @@ -383,13 +398,14 @@ def _kilo_auth_token() -> str | None: return None if not isinstance(data, dict): return None - provider = data.get(_DEFAULT_KILO_PROVIDER) - if not isinstance(provider, dict): - return None - key = provider.get("key", "") - if not isinstance(key, str): - return None - return key.strip() or None + for provider_name in _KILO_PROVIDER_FALLBACKS: + provider = data.get(provider_name) + if not isinstance(provider, dict): + continue + key = provider.get("key", "") + if isinstance(key, str) and key.strip(): + return key.strip() + return None def _gh_config_token() -> str | None: @@ -462,8 +478,8 @@ def _token(backend: str) -> str: if backend == "kilo": raise _Error( "No FNAL LiteLLM token found. " - "Ensure kilo is configured with the fnal-litellm provider, or " - "set GIT_AI_COMMIT_TOKEN." + "Ensure kilo is configured with the fnal-azure or fnal-ow " + "provider, or set GIT_AI_COMMIT_TOKEN." ) raise _Error( "No API token found. Set GIT_AI_COMMIT_TOKEN, GITHUB_TOKEN, or run 'gh auth login'." diff --git a/scripts/man/man1/git-ai-commit.1 b/scripts/man/man1/git-ai-commit.1 index 0600e6a13..2d6793a0c 100644 --- a/scripts/man/man1/git-ai-commit.1 +++ b/scripts/man/man1/git-ai-commit.1 @@ -136,7 +136,7 @@ Token resolution order: .IP "1." 4 \fBGIT_AI_COMMIT_TOKEN\fR .IP "2." 4 -\fB~/.local/share/kilo/auth.json\fR (\fIfnal\-litellm\fR key) +\fB~/.local/share/kilo/auth.json\fR (\fIfnal\-azure\fR / \fIfnal\-ow\fR key) .IP "3." 4 \fBGITHUB_TOKEN\fR / \fBGH_TOKEN\fR .IP "4." 4 @@ -197,7 +197,10 @@ Default model for the \fBkilo\fR backend. Default: \fBazure/claude\-haiku\-4\-5 Setting this variable suppresses auto\-escalation even for large prompts. .TP \fBGIT_AI_COMMIT_KILO_PROVIDER\fR -Provider key in \fB~/.local/share/kilo/auth.json\fR. Default: \fBfnal\-litellm\fR. +Provider key in \fB~/.local/share/kilo/auth.json\fR. When unset, the split +FNAL providers are tried in order: \fBfnal\-azure\fR, then \fBfnal\-ow\fR, then +the retired \fBfnal\-litellm\fR. These share the same upstream gateway token, +so the first key present is used. .TP \fBEDITOR\fR, \fBVISUAL\fR Editor invoked by \fB\-\-edit\fR. Checked in order; \fBvi\fR is the final fallback. @@ -246,7 +249,8 @@ echo "Closes #1234; keep message terse" | git ai\-commit \-\-yes .SH FILES .TP \fB~/.local/share/kilo/auth.json\fR -Kilo credentials file; read for the \fBfnal\-litellm\fR provider key. +Kilo credentials file; read for the \fBfnal\-azure\fR / \fBfnal\-ow\fR provider +key (legacy \fBfnal\-litellm\fR still honored if present). .TP \fB~/.config/git\-ai\-commit/instructions.md\fR User\-level instructions included in every prompt. diff --git a/scripts/test/test_git_ai_commit.py b/scripts/test/test_git_ai_commit.py index cbce95ee0..a527a1700 100644 --- a/scripts/test/test_git_ai_commit.py +++ b/scripts/test/test_git_ai_commit.py @@ -67,7 +67,7 @@ class TestKiloAuthToken: """_kilo_auth_token validates the kilo credentials file before reading.""" - _PROVIDER = "fnal-litellm" + _PROVIDER = "fnal-azure" def _write(self, path: Path, obj: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -75,7 +75,9 @@ def _write(self, path: Path, obj: object) -> None: def _setup(self, monkeypatch: pytest.MonkeyPatch, path: Path) -> None: monkeypatch.setattr(_M, "_KILO_AUTH_JSON", path) - monkeypatch.setattr(_M, "_DEFAULT_KILO_PROVIDER", self._PROVIDER) + # _kilo_auth_token iterates _KILO_PROVIDER_FALLBACKS; pin it to a single + # provider for the single-provider test cases below. + monkeypatch.setattr(_M, "_KILO_PROVIDER_FALLBACKS", [self._PROVIDER]) def test_file_missing_returns_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -173,6 +175,42 @@ def test_valid_key_no_whitespace( self._setup(monkeypatch, p) assert _kilo_auth_token() == "tok123" + def test_provider_fallback_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """First present provider in the fallback list wins. + + The retired fnal-litellm provider was split into fnal-azure and + fnal-ow; _kilo_auth_token tries the list in order and returns the first + provider that has a usable key, skipping absent earlier entries. + """ + p = tmp_path / "auth.json" + # fnal-azure absent; fnal-ow present -> fnal-ow key is used. + self._write(p, {"fnal-ow": {"key": "ow-token"}}) + monkeypatch.setattr(_M, "_KILO_AUTH_JSON", p) + monkeypatch.setattr( + _M, "_KILO_PROVIDER_FALLBACKS", ["fnal-azure", "fnal-ow", "fnal-litellm"] + ) + assert _kilo_auth_token() == "ow-token" + + def test_provider_fallback_prefers_earlier( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When multiple providers are present, the earliest in the list wins.""" + p = tmp_path / "auth.json" + self._write( + p, + { + "fnal-azure": {"key": "azure-token"}, + "fnal-ow": {"key": "ow-token"}, + }, + ) + monkeypatch.setattr(_M, "_KILO_AUTH_JSON", p) + monkeypatch.setattr( + _M, "_KILO_PROVIDER_FALLBACKS", ["fnal-azure", "fnal-ow", "fnal-litellm"] + ) + assert _kilo_auth_token() == "azure-token" + # =========================================================================== # _gh_cli_token