diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d7309855 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# wally build configuration — local file, never committed. +# +# Copy `.env.example` to `.env` and fill in the two console endpoints. +# Read by cmake/env-loader.cmake at configure time. Configure fails without +# these; the values end up compiled into the binary, so they never appear in +# committed source or on a command line. + +# The control plane: /auth/cli/*, /v1/me, /v1/cli/*. +WALLY_BAKED_CONSOLE_API_URL= + +# The browser origin allowed to host the approval page. +WALLY_BAKED_CONSOLE_WEB_ORIGIN= + +# Optional: one more browser origin allowed to host the approval page, for a +# deployment that answers under a second name. +#WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT= diff --git a/.github/actions/build-wally/action.yml b/.github/actions/build-wally/action.yml index e25a1bcc..f9896c5f 100644 --- a/.github/actions/build-wally/action.yml +++ b/.github/actions/build-wally/action.yml @@ -2,9 +2,9 @@ name: Build wally description: >- Configure, build, and optionally package a wally bottle from a pinned C++ desktop kit. The configure→build sequence is identical across ci.yml and - release.yml and every platform; this is the one place it lives. The dev/prod - bake is read from the WALLY_BAKED_CONSOLE_* environment the caller sets (empty - for prod), so nothing endpoint-shaped is passed here. + release.yml and every platform; this is the one place it lives. The console + endpoints are read from the WALLY_BAKED_CONSOLE_* environment the caller sets, + so nothing endpoint-shaped is passed here. inputs: platform: @@ -13,10 +13,6 @@ inputs: kit-dir: description: Path to the fetched kit (CMAKE_PREFIX_PATH). required: true - variant: - description: prod | dev — only affects the packaged filename (-dev). - required: false - default: prod package: description: 'true to also produce the release bottle; false to only build.' required: false @@ -29,13 +25,14 @@ runs: with: platform: ${{ inputs.platform }} - - name: Configure + build (${{ inputs.variant }}) + - name: Configure + build shell: bash - env: - # Declared so configure can refuse a dev build whose endpoint variables - # never arrived, instead of quietly producing a production binary under - # a -dev name (wally #87). - WALLY_CHANNEL: ${{ inputs.variant }} + # WALLY_BAKED_CONSOLE_* and WALLY_CONSOLE_OPTIONAL come from the caller's + # job environment; composite steps inherit job-level env. Configure + # requires the endpoints so a binary can never silently ship with no + # control plane; a fork pull request receives no repository variables, so + # the caller sets WALLY_CONSOLE_OPTIONAL=1 there and the resulting binary + # resolves no default console. run: | set -euo pipefail prefix="${{ inputs.kit-dir }}" @@ -59,7 +56,7 @@ runs: *) cmake --build build --config Release ;; esac - - name: Package (${{ inputs.variant }}) + - name: Package if: ${{ inputs.package == 'true' }} shell: bash env: @@ -69,13 +66,13 @@ runs: ver="${WALLY_VERSION#v}" case "${{ inputs.platform }}" in macos-arm64) - WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build macos-arm64 "${{ inputs.variant }}" ;; + WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build macos-arm64 ;; linux-x64) - WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build linux-x86_64 "${{ inputs.variant }}" ;; + WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build linux-x86_64 ;; linux-arm64) - WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build linux-arm64 "${{ inputs.variant }}" ;; + WALLY_VERSION="$ver" bash scripts/build/package-wally.sh build linux-arm64 ;; windows-x64) - pwsh -File scripts/build/package-wally-windows.ps1 -BuildDir build -Version "$ver" -KitDir "${{ inputs.kit-dir }}" -Channel "${{ inputs.variant }}" ;; + pwsh -File scripts/build/package-wally-windows.ps1 -BuildDir build -Version "$ver" -KitDir "${{ inputs.kit-dir }}" ;; windows-arm64) - pwsh -File scripts/build/package-wally-windows.ps1 -BuildDir build -Version "$ver" -KitDir "${{ inputs.kit-dir }}" -Platform windows-arm64 -Channel "${{ inputs.variant }}" ;; - esac + pwsh -File scripts/build/package-wally-windows.ps1 -BuildDir build -Version "$ver" -KitDir "${{ inputs.kit-dir }}" -Platform windows-arm64 ;; + esac \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf919878..1a49c6c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,16 @@ on: permissions: contents: read +# The console endpoints configure and bake into the binary. They are repository +# variables, not secrets: nothing here grants access, and a fork pull request +# receives neither. WALLY_CONSOLE_OPTIONAL lets a fork PR build without them +# (the binary then resolves no default console). Builds on this repo use the +# existing dev-console variables. The alt origin is optional and unset here. +env: + WALLY_BAKED_CONSOLE_API_URL: ${{ vars.WALLY_DEV_CONSOLE_API_URL }} + WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ vars.WALLY_DEV_CONSOLE_WEB_ORIGIN }} + WALLY_CONSOLE_OPTIONAL: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork && '1' || '' }} + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -101,7 +111,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: b3a2b0147d8c66190c14a53bc04286efd6a4d64a + ref: e595580a72452595a4a5b8abc137cad10132ba75 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 812a871a..cf7d1e17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,26 +10,61 @@ on: version: description: 'Version to release (e.g. 0.5.0 — no leading v)' required: true + dry_run: + description: 'Build, package and verify everything without creating a tag or release (version must match versions.toml)' + required: false + default: false + type: boolean permissions: contents: write env: WALLY_VERSION: ${{ github.event.inputs.version || github.ref_name }} + # Required at configure time; a release never falls back to an un-configured + # binary, so these must be set as repository variables before tagging. + WALLY_BAKED_CONSOLE_API_URL: ${{ vars.WALLY_CONSOLE_API_URL }} + WALLY_BAKED_CONSOLE_WEB_ORIGIN: ${{ vars.WALLY_CONSOLE_WEB_ORIGIN }} + WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT: ${{ vars.WALLY_CONSOLE_WEB_ORIGIN_ALT }} -# Every platform builds one bottle from one kit: `prod` (empty bake -> the -# production console defaults in credentials.cpp). The configure→build→package +# Every platform builds one bottle from one kit, with the console endpoints from +# the repository variables above compiled in. The configure→build→package # sequence lives in .github/actions/build-wally; e2e runs on the binary it # produced, so the bottle is built exactly once. +# +# Dry run: dispatch with dry_run=true and the current versions.toml version. +# Everything builds, packages and verifies, but the final release step is +# skipped, so no tag and no GitHub Release are created. jobs: + validate-dispatch: + # Fails fast on a bad manual input instead of burning five platform + # builds. Always runs so `needs` below never sees a skipped job on push. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Validate dispatch inputs + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be X.Y.Z (no leading v); got '$VERSION'" >&2 + exit 1 + fi + pinned="$(awk '/^\[product\]/{p=1} p && /^version = "/{gsub(/"/, "", $3); print $3; exit}' versions.toml)" + if [[ "$DRY_RUN" == "true" && "$VERSION" != "$pinned" ]]; then + echo "::error::dry_run needs version=$pinned (versions.toml [product]); got '$VERSION'" >&2 + exit 1 + fi macos: + needs: [validate-dispatch] # SDK Package.swift is swift-tools-version 6.2 (Xcode 26). runs-on: macos-26 timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - variant: [prod] env: WALLY_SDK_SWIFT_PATH: ${{ github.workspace }}/.deps/runanywhere-sdks steps: @@ -41,7 +76,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: b3a2b0147d8c66190c14a53bc04286efd6a4d64a + ref: e595580a72452595a4a5b8abc137cad10132ba75 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja @@ -75,7 +110,6 @@ jobs: with: platform: macos-arm64 kit-dir: ${{ github.workspace }}/kit - variant: ${{ matrix.variant }} package: 'true' - name: Product e2e env: @@ -89,16 +123,13 @@ jobs: "dist/wally-${VERSION}-macos-arm64.tar.gz.sha256" - uses: actions/upload-artifact@v4 with: - name: wally-macos-arm64-${{ matrix.variant }} + name: wally-macos-arm64 path: dist/wally-*-macos-arm64*.tar.gz* windows-arm64: + needs: [validate-dispatch] runs-on: windows-11-arm timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -139,7 +170,6 @@ jobs: with: platform: windows-arm64 kit-dir: ${{ github.workspace }}/kit - variant: ${{ matrix.variant }} package: 'true' - name: Product e2e shell: bash @@ -160,19 +190,16 @@ jobs: "dist/wally-$ver-windows-arm64.zip.sha256" - uses: actions/upload-artifact@v4 with: - name: wally-windows-arm64-${{ matrix.variant }} + name: wally-windows-arm64 path: | dist/*.zip dist/*.zip.sha256 if-no-files-found: error windows: + needs: [validate-dispatch] runs-on: windows-2022 timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -211,7 +238,6 @@ jobs: with: platform: windows-x64 kit-dir: ${{ github.workspace }}/kit - variant: ${{ matrix.variant }} package: 'true' - name: Product e2e shell: bash @@ -232,21 +258,18 @@ jobs: "dist/wally-$ver-windows-x86_64.zip.sha256" - uses: actions/upload-artifact@v4 with: - name: wally-windows-x64-${{ matrix.variant }} + name: wally-windows-x64 path: | dist/*.zip dist/*.zip.sha256 if-no-files-found: error linux: + needs: [validate-dispatch] # Release compatibility floor. Ubuntu 24.04 produced a bottle requiring # GLIBC_2.38 and GLIBCXX_3.4.31/32, which fails on Ubuntu 22.04. runs-on: ubuntu-22.04 timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -285,7 +308,6 @@ jobs: with: platform: linux-x64 kit-dir: ${{ github.workspace }}/kit - variant: ${{ matrix.variant }} package: 'true' - name: Product e2e env: @@ -312,10 +334,11 @@ jobs: bash scripts/test/linux-install-matrix.sh dist "${VERSION}" - uses: actions/upload-artifact@v4 with: - name: wally-linux-x86_64-${{ matrix.variant }} + name: wally-linux-x86_64 path: dist/wally-*-linux-x86_64*.tar.gz* linux-arm64: + needs: [validate-dispatch] # Built on the arm64 compat floor (glibc 2.34) so wally's own code stays # low; the kit's Sherpa prebuilt (glibc 2.38) sets the bottle floor. The # arm64 kit's cpp-httplib (zstd, brotli) and libarchive (bz2) reference @@ -327,10 +350,6 @@ jobs: # floor from the ELF files instead. runs-on: ubuntu-22.04-arm timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - variant: [prod] steps: - uses: actions/checkout@v4 with: @@ -370,7 +389,6 @@ jobs: with: platform: linux-arm64 kit-dir: ${{ github.workspace }}/kit - variant: ${{ matrix.variant }} package: 'true' - name: Verify archive run: | @@ -388,7 +406,7 @@ jobs: "dist/wally-${VERSION}-linux-arm64.tar.gz" - uses: actions/upload-artifact@v4 with: - name: wally-linux-arm64-${{ matrix.variant }} + name: wally-linux-arm64 path: dist/wally-*-linux-arm64*.tar.gz* publish: @@ -455,6 +473,10 @@ jobs: ( cd artifacts && shasum -a 256 "$f" > "${f}.sha256" ) done - name: Create the release + # Dry runs stop here: bottles and metadata stay workflow artifacts, + # no tag and no GitHub Release. On tag pushes `inputs` is empty, so + # this still runs for real releases. + if: ${{ inputs.dry_run != true }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event_name == 'push' && github.ref_name || format('v{0}', github.event.inputs.version) }} diff --git a/.gitignore b/.gitignore index 95e0a807..41e7e052 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ deps/ BEST_PRACTISES.md BEST_PRACTICES.md +# Local build configuration (copy .env.example; see cmake/WallyRust.cmake). +.env + # IDE / editor caches .cache/ .vscode/ diff --git a/AGENTS.md b/AGENTS.md index eb0ca406..f0c8213c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,15 @@ The separately vendored `contracts/wally-decisions-public-v1.openapi.json` is pinned to its InferenceInfra commit in `versions.toml`; the same generator emits `src/account/decisions_contract.rs` with `--operation-id createDecisions`. +`wally decisions` has two transports behind one contract. Cloud posts the +request as above; local (`--local`, or a `-m` that resolves to a local +decision artifact) runs it through the kit's `rac_decision_*` component and +maps the proto result back onto the same `DecisionsResponse`, so rendering and +`--json` are transport-independent. The two local rows (`clef-flash-9b`, +`clef-flash-mlx`) are `ModelCategory::Decision` and are the only +non-Language entries the LLM-only catalog cut surfaces (`is_llm` in +`src/catalog/catalog.rs`, mirrored in `cmd_list.rs`'s row filter). + To re-vendor from an InferenceInfra checkout (records the source commit on the extract): @@ -158,28 +167,36 @@ Two hosts, and they are not the same deployment. The API is the control plane and serves all four endpoints above plus `/v1/cli/*`; the web console is only the page a person approves the sign-in on. -| | Default | Override | +Both are **configure inputs, not source constants**. Configure requires: + +| | Supplies | Runtime override | |---|---|---| -| API | `https://inference.runanywhere.ai` | `WALLY_CONSOLE_URL` | -| Approval page | `https://console.runanywhere.ai` | `WALLY_CONSOLE_WEB_URL` | - -Both defaults live together in `src/account/credentials.rs` so they cannot -drift apart, and `TrustedBrowserOrigins()` is what pairs them: with no override -it trusts the deployed console, and for any other API origin it trusts only -that origin. Pointing at a local dev console needs `WALLY_CONSOLE_URL` set -explicitly, e.g. `WALLY_CONSOLE_URL=http://localhost:8080`. - -The API URL may carry a path, because the deployed development console is one: -`https://inference.runanywhere.ai/api-dev`, where the load balancer strips the -prefix and forwards to the dev control plane. Every endpoint is appended to -whatever is configured, so the prefix follows the whole flow. Its approval page -is on Railway rather than that host, so dev also needs -`WALLY_CONSOLE_WEB_URL=https://runanywhere-frontend-development.up.railway.app`. - -Do not reach for the dev backend's own Cloud Run hostname instead. It answers, -but it is behind the load balancer, so `/v1/chat/completions` lands on the -control plane rather than the gateway and returns 502 — a route no installed -binary can produce, and a day lost to debugging it. +| Control plane | `WALLY_BAKED_CONSOLE_API_URL` | `WALLY_CONSOLE_URL` | +| Approval page | `WALLY_BAKED_CONSOLE_WEB_ORIGIN` (+ optional `…_ALT`) | `WALLY_CONSOLE_WEB_URL` | + +Copy `.env.example` to `.env` and fill them in, or export them before +`cmake -B build`. Configure fails without the first two, so a binary can never +ship without a control plane; the values live in the build tree, never in +committed source. `cmake/env-loader.cmake` reads `.env`; real environment +variables win. CI takes them from repository variables, and a fork pull request +(which receives none) sets `WALLY_CONSOLE_OPTIONAL=1`, producing a binary that +resolves no default console until `WALLY_CONSOLE_URL` is set. + +`TrustedBrowserOrigins()` pairs the two: it trusts the configured approval +origins while talking to the configured API, and for any other API origin it +trusts only that origin. Pointing at a local dev console needs +`WALLY_CONSOLE_URL` set explicitly, e.g. `WALLY_CONSOLE_URL=http://localhost:8080`. + +A console URL may carry a path: a deployment reached at `https://host/prefix` +has the load balancer strip the prefix and forward to the control plane behind +it. Every endpoint is appended to whatever is configured, so the prefix follows +the whole flow, and the approval page lives on its own origin, so that needs +`WALLY_CONSOLE_WEB_URL` set too. + +Do not reach for a backend's own Cloud Run hostname instead. It answers, but it +is behind the load balancer, so `/v1/chat/completions` lands on the control +plane rather than the gateway and returns 502 — a route no installed binary can +produce, and a day lost to debugging it. `WALLY_PROFILE_DIR` moves the credential file, which is what lets several accounts share one machine. diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c275ead..54aea204 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,54 +34,59 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(RunAnywhereSDK) find_package(Threads REQUIRED) -# Dev endpoint bake. The values come from the ENVIRONMENT of the configure -# step, never from -D flags: nothing endpoint-shaped lands on the command -# line, in CMakeCache.txt, or in this log (only ON/off is printed). They reach -# the Rust crate through generated/wally-build.env, which lives in the build -# tree and is gitignored — no endpoint ever enters committed source. Empty env -# (the normal case) bakes nothing and the production resolution order is -# untouched. +include(env-loader) + +# Console endpoints. Required at configure time: the control plane this build +# talks to, and the browser origin(s) allowed to host the approval page. Values +# come from the ENVIRONMENT of the configure step (or the repo-local .env that +# cmake/env-loader.cmake loads), never from -D flags: nothing endpoint-shaped +# lands on the command line, in CMakeCache.txt, or in this log. They reach the +# Rust crate through generated/wally-build.env in the build tree, which is +# gitignored — no endpoint enters committed source. A configure without them is +# refused, so a binary can never resolve a console it does not know; +# WALLY_CONSOLE_OPTIONAL=1 opts out for CI runs that have no repository +# configuration (pull requests from forks), and that binary reports no default +# console at runtime. set(WALLY_BAKED_CONSOLE_API_URL "$ENV{WALLY_BAKED_CONSOLE_API_URL}") set(WALLY_BAKED_CONSOLE_WEB_ORIGIN "$ENV{WALLY_BAKED_CONSOLE_WEB_ORIGIN}") -# WALLY_CHANNEL states what is being built. A release job that means to build a -# dev bottle but whose endpoint variables are unset would otherwise produce a -# `-dev` archive that quietly defaults to production, because an empty bake is -# indistinguishable from an ordinary production build (wally #87). Declaring the -# intent is what lets that be caught here rather than by a person wondering why -# nightly is billing them. -set(WALLY_CHANNEL "$ENV{WALLY_CHANNEL}") -if(WALLY_CHANNEL STREQUAL "dev") - if(NOT WALLY_BAKED_CONSOLE_API_URL OR NOT WALLY_BAKED_CONSOLE_WEB_ORIGIN) +set(WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT "$ENV{WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT}") +set(WALLY_CONSOLE_OPTIONAL "$ENV{WALLY_CONSOLE_OPTIONAL}") + +if(NOT WALLY_BAKED_CONSOLE_API_URL OR NOT WALLY_BAKED_CONSOLE_WEB_ORIGIN) + if(WALLY_CONSOLE_OPTIONAL STREQUAL "1") + message(WARNING + "wally: building with no console endpoints (WALLY_CONSOLE_OPTIONAL=1). " + "This binary resolves no control plane until WALLY_CONSOLE_URL is set.") + else() message(FATAL_ERROR - "WALLY_CHANNEL=dev needs both WALLY_BAKED_CONSOLE_API_URL and " - "WALLY_BAKED_CONSOLE_WEB_ORIGIN in the configure environment. " - "Without them this builds a production binary under a -dev name.") + "wally: console endpoints are required to configure.\n" + " Set WALLY_BAKED_CONSOLE_API_URL (the control plane) and " + "WALLY_BAKED_CONSOLE_WEB_ORIGIN (the approval page's origin) in the " + "configure environment, or in a repo-local .env (copy .env.example).\n" + " WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT is optional: one more browser " + "origin allowed to host the approval page.") endif() endif() -# Half a pair is a configuration mistake in either direction: an API with no -# approval origin cannot complete a login, and an origin with no API is trusted -# for nothing. +# A half a pair is a configuration mistake when one of them is set: an API +# with no approval origin cannot complete a login, and an origin with no API +# is trusted for nothing. if(WALLY_BAKED_CONSOLE_API_URL AND NOT WALLY_BAKED_CONSOLE_WEB_ORIGIN) message(FATAL_ERROR "WALLY_BAKED_CONSOLE_API_URL is set without " "WALLY_BAKED_CONSOLE_WEB_ORIGIN; `wally login` could not " "trust any approval page.") endif() -if(WALLY_BAKED_CONSOLE_WEB_ORIGIN AND NOT WALLY_BAKED_CONSOLE_API_URL) - message(FATAL_ERROR "WALLY_BAKED_CONSOLE_WEB_ORIGIN is set without " - "WALLY_BAKED_CONSOLE_API_URL; the origin is paired with " - "an API that was not baked.") -endif() -foreach(_baked_url "${WALLY_BAKED_CONSOLE_API_URL}" "${WALLY_BAKED_CONSOLE_WEB_ORIGIN}") +foreach(_baked_url "${WALLY_BAKED_CONSOLE_API_URL}" "${WALLY_BAKED_CONSOLE_WEB_ORIGIN}" + "${WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT}") if(_baked_url AND NOT _baked_url MATCHES "^https?://[^/ ]+") message(FATAL_ERROR - "a baked endpoint is not an http(s) origin. Values are withheld from " + "a console endpoint is not an http(s) origin. Values are withheld from " "this log; check the configure environment.") endif() endforeach() if(WALLY_BAKED_CONSOLE_API_URL) - message(STATUS "wally: dev endpoint bake ON (values withheld from log)") + message(STATUS "wally: console endpoints configured (values withheld from log)") else() - message(STATUS "wally: dev endpoint bake off (production defaults)") + message(STATUS "wally: no console endpoints (WALLY_CONSOLE_OPTIONAL)") endif() # The model a harness launch falls back to when the reader passes no -m and set diff --git a/Cargo.lock b/Cargo.lock index a60ca4f2..5b7ef255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,7 +1410,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wally" -version = "0.7.3" +version = "0.8.1" dependencies = [ "brotli-decompressor", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index 0c814aa3..866832cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "wally" # Must equal [product] version in versions.toml (scripts/ci/check-versions.py). -version = "0.7.3" +version = "0.8.1" edition = "2021" rust-version = "1.93" license = "Apache-2.0" diff --git a/Formula/wally.rb b/Formula/wally.rb index 75465b0d..a712073f 100644 --- a/Formula/wally.rb +++ b/Formula/wally.rb @@ -3,15 +3,15 @@ class Wally < Formula desc "Run language, speech and image models on your own machine" homepage "https://github.com/RunanywhereAI/wally" - version "0.7.3" + version "0.8.1" license "MIT" # macOS arm64 ships the Swift MLX host # (llama.cpp + ONNX + Sherpa + MLX). Linux is not in this cut. on_macos do on_arm do - url "https://github.com/RunanywhereAI/wally/releases/download/v0.7.3/wally-0.7.3-macos-arm64.tar.gz" - # Placeholder -- the v0.7.3 release has not published a wally-named asset + url "https://github.com/RunanywhereAI/wally/releases/download/v0.8.1/wally-0.8.1-macos-arm64.tar.gz" + # Placeholder -- the v0.8.1 release has not published a wally-named asset # yet. scripts/release/update-tap.sh re-stamps this from the real release # checksum; a stale value here fails brew install's own hash check # rather than installing something unverified. diff --git a/README.md b/README.md index 68435e00..d4b1a11a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ wally run qwen3 "Hello" # one answer and exit wally serve qwen3 # OpenAI-compatible API on :8080 (macOS, Linux) ``` -`wally models list --all` shows everything you can pull. Any Hugging Face GGUF +`wally models list --local` shows everything you can pull; `--cloud` shows the +cloud models on your account, and `--all` shows both. Any Hugging Face GGUF works too, by its full path: ```bash @@ -113,6 +114,19 @@ Use `--json` for the raw API response, or `--request FILE` for the complete typed request shape. Decision models are refused by `wally run` and the coding tools, which point back here. +Eve is also runnable on this machine. Pull a local checkpoint and point +`-m` at it; `--local` (or just a local `-m`) scores in-process through the +SDK's decision component, `--cloud` forces the hosted path: + +```bash +wally models pull clef-flash-9b +wally decisions --local -m clef-flash-9b \ + --input "Checkout is blank after Pay" --ask "Is this a software bug?" +``` + +Both transports share the request flags, the human rendering and the `--json` +document, so the same invocation works locally and hosted. + ## Models at a glance | Family | Pull | @@ -137,7 +151,7 @@ has the full catalog. | Models | | |---|---| -| `wally models list --all` | models on this machine, or the whole catalog | +| `wally models list` | models on this machine; `--local` for the whole catalog, `--cloud` for your cloud models, `--all` for both | | `wally models pull` / `rm` | download or delete a model | | `wally models show` | size, context window, files | | `wally models default` | the model coding tools open with | diff --git a/build.rs b/build.rs index 77a76555..cf40636b 100644 --- a/build.rs +++ b/build.rs @@ -12,10 +12,11 @@ //! versions.toml. No protoc runs and nothing generated is committed. //! - `HAS_*`: the engine/component capability flags the C++ build got as //! `WALLY_HAS_*` compile definitions; each becomes `cfg(wally_has_*)`. -//! - `DEFAULT_MODEL_ID`, `BAKED_CONSOLE_API_URL`, `BAKED_CONSOLE_WEB_ORIGIN`: -//! exported to the crate as compile-time env values. The endpoints are only ever -//! set for a dev-channel build; they live in the build tree, as the generated -//! C++ header did, and are never printed. +//! - `DEFAULT_MODEL_ID`, `CONSOLE_*`: exported to the crate as compile-time env +//! values. The console endpoints are required configure inputs; a build with +//! none (CI on a fork pull request) exports empty values and resolves no +//! default console at runtime. They live in the build tree and are never +//! printed. //! - `CMAKE_FILE_API_REPLY` / `LINK_PROBE_TARGET`: where CMake recorded the link //! line of `wally_link_probe` — the kit closure the C++ `wally` executable had. //! The static library needs none of it; every binary cargo links does. @@ -26,6 +27,7 @@ use std::collections::BTreeMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use sha2::{Digest, Sha256}; @@ -72,12 +74,22 @@ fn main() { println!("cargo:rerun-if-changed={}", env_file.display()); let build_env = match fs::read_to_string(&env_file) { Ok(text) => parse_env(&text), - Err(_) => panic!( - "{} not found. Configure CMake first so the SDK kit is resolved:\n \ - cmake -B build -G Ninja -DCMAKE_PREFIX_PATH=/path/to/kit\n\ - or set WALLY_BUILD_ENV to the wally-build.env of another build dir.", - env_file.display() - ), + Err(_) => { + // CMake owns the kit: discovery, auto-fetch, pin checks and the link + // closure. An IDE's first sync runs bare `cargo build` without ever + // configuring CMake, so kick it once from here. The fetched kit and + // the generated env file persist, so later builds never re-enter. + self_configure(&manifest, &env_file); + let text = fs::read_to_string(&env_file).unwrap_or_else(|e| { + panic!( + "{} still missing after configure: {e}. The kit fetch needs \ + `gh` signed in (`gh auth login`), or point WALLY_BUILD_ENV \ + at the wally-build.env of another build dir.", + env_file.display() + ) + }); + parse_env(&text) + } }; // Capability flags → cfg(wally_has_*), declared so check-cfg knows them. @@ -98,7 +110,14 @@ fn main() { .cloned() .unwrap_or_else(|| "glm-5.3-flash".to_string()); println!("cargo:rustc-env=WALLY_DEFAULT_MODEL_ID={default_model}"); - for key in ["BAKED_CONSOLE_API_URL", "BAKED_CONSOLE_WEB_ORIGIN"] { + // The console endpoints this binary talks to. Required configure inputs + // (CMakeLists.txt); CI passes empty values for fork pull requests, which + // WALLY_CONSOLE_API_URL can still override at runtime. + for key in [ + "CONSOLE_API_URL", + "CONSOLE_WEB_ORIGIN", + "CONSOLE_WEB_ORIGIN_ALT", + ] { let value = build_env.get(key).cloned().unwrap_or_default(); println!("cargo:rustc-env=WALLY_{key}={value}"); } @@ -113,6 +132,36 @@ fn main() { link_native(&build_env); } +/// Run the CMake configure that writes the build env file. Only when it is +/// absent: a normal flow (CMake driving cargo) never lands here, and a failed +/// fetch leaves the panic above to explain why. When the env path follows the +/// `/generated/wally-build.env` contract, its build dir is the one +/// configured; anything else points at a tree we cannot configure, so the +/// default build dir is the best guess. Ninja is named only when that dir is +/// new; CMake refuses to change an existing dir's generator. +fn self_configure(manifest: &Path, env_file: &Path) { + let build_dir = env_file + .parent() + .filter(|p| p.ends_with("generated")) + .and_then(|generated| generated.parent()) + .map(Path::to_path_buf) + .unwrap_or_else(|| manifest.join("build")); + let mut cmd = Command::new("cmake"); + cmd.arg("-B").arg(&build_dir); + if !build_dir.join("CMakeCache.txt").exists() { + cmd.arg("-G").arg("Ninja"); + } + let status = cmd.current_dir(manifest).status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => panic!( + "cmake configure failed ({s}); run it manually for the full output:\ + \n cmake -B build -G Ninja" + ), + Err(_) => panic!("cmake not found on PATH; install it to build wally"), + } +} + /// `key = "value"` lines from versions.toml (flat by design; see its header). fn read_versions(path: &Path) -> BTreeMap { let text = fs::read_to_string(path).expect("cannot read versions.toml"); diff --git a/cmake/WallyRust.cmake b/cmake/WallyRust.cmake index b5984ad8..0e7a31ef 100644 --- a/cmake/WallyRust.cmake +++ b/cmake/WallyRust.cmake @@ -8,9 +8,8 @@ # # generated/wally-build.env written here at configure time; build.rs reads it # for the kit IDL dir, the engine capability flags, -# the default model and (dev channel only) the -# baked console endpoints — values never go on a -# command line. +# the default model and the console endpoints — +# values never go on a command line. # the CMake file API reply the exact link line of `wally_link_probe`, an # executable that is configured but never built. # build.rs turns its link fragments into linker @@ -109,8 +108,9 @@ KIT_ROOT=${WALLY_SDK_ROOT} KIT_IDL_DIR=${RunAnywhere_IDL_DIR} KIT_THIRD_PARTY_DIR=${RunAnywhere_THIRD_PARTY_DIR} ${_wally_caps}DEFAULT_MODEL_ID=${WALLY_DEFAULT_MODEL_ID} -BAKED_CONSOLE_API_URL=${WALLY_BAKED_CONSOLE_API_URL} -BAKED_CONSOLE_WEB_ORIGIN=${WALLY_BAKED_CONSOLE_WEB_ORIGIN} +CONSOLE_API_URL=${WALLY_BAKED_CONSOLE_API_URL} +CONSOLE_WEB_ORIGIN=${WALLY_BAKED_CONSOLE_WEB_ORIGIN} +CONSOLE_WEB_ORIGIN_ALT=${WALLY_BAKED_CONSOLE_WEB_ORIGIN_ALT} CMAKE_FILE_API_REPLY=${CMAKE_BINARY_DIR}/.cmake/api/v1/reply LINK_PROBE_TARGET=wally_link_probe LINK_PROBE_CONFIG=${CMAKE_BUILD_TYPE} diff --git a/cmake/env-loader.cmake b/cmake/env-loader.cmake new file mode 100644 index 00000000..2a796d30 --- /dev/null +++ b/cmake/env-loader.cmake @@ -0,0 +1,22 @@ +# A repo-local .env (copy .env.example) feeds the configure environment, so a +# fresh clone can pick a channel with one file instead of exporting variables +# by hand. Values stay out of the repo and the command line; environment +# variables still win, which is how CI keeps passing its own. Included before +# CMakeLists.txt reads $ENV{WALLY_*}. +set(_wally_env_file "${CMAKE_SOURCE_DIR}/.env") +if(EXISTS "${_wally_env_file}") + file(STRINGS "${_wally_env_file}" _wally_env_lines) + foreach(_line IN LISTS _wally_env_lines) + # Only KEY=value lines; comments and blanks are skipped. + if(_line MATCHES "^[A-Za-z_][A-Za-z0-9_]*=") + string(REGEX REPLACE "^([A-Za-z_][A-Za-z0-9_]*)=(.*)$" "\\1" _key "${_line}") + string(REGEX REPLACE "^([A-Za-z_][A-Za-z0-9_]*)=(.*)$" "\\2" _value "${_line}") + string(STRIP "${_value}" _value) + # Tolerate KEY="value" as well as KEY=value. + string(REGEX REPLACE "^\"(.*)\"$" "\\1" _value "${_value}") + if(NOT DEFINED ENV{${_key}}) + set(ENV{${_key}} "${_value}") + endif() + endif() + endforeach() +endif() \ No newline at end of file diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 67bf745e..5181b0c3 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -40,6 +40,14 @@ which is what lets an agent on the far side run the tools it was given rather than describe them. `opencode`, `hermes` and `openclaw` speak OpenAI already, so they talk to the endpoint directly. +The model's reasoning shows up as a thinking block with an empty signature, +because these models can't sign it. Claude Code accepts that, and wally drops +the block again when Claude Code sends it back. Cache reads show up as +`cache_read_input_tokens`, taken out of `input_tokens` so nothing is counted +twice. Cache writes aren't reported, because the endpoint doesn't report them. +OpenCode, OpenClaw and Prime Agent get the catalog's cache-read price, so their +cost display charges a cached prompt at the cached rate. + Nothing wally writes for a tool outlives the run. The variables go in the child process. DeepSeek Harness is handed a patch in a temp directory, deleted when it exits; its own `$DSH_HOME` is never written to, and the API key never enters diff --git a/docs/MODELS.md b/docs/MODELS.md index 7ba86ae4..90a9a0fe 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -1,6 +1,6 @@ # Models -`wally models list --all` is the live list. +`wally models list --local` is the live list. `--cloud` lists the cloud models on your account, and `--all` shows both. This table groups models by publisher; the id you pull depends on the engine you want: @@ -34,8 +34,20 @@ archive. The rows are commented out in `src/catalog/catalog.rs` under ### Decisions -`eve` is hosted only and returns label probabilities rather than generated -text. Use `wally decisions` (alias `wally decide`), not `wally run` or a coding -harness; it is the default model there, so `-m` is optional. It supports -yes/no (`--ask`), choices (`--choice`) and ordered levels (`--score`); -`wally decisions --help` shows the request-file and JSON forms. +Decision models return label probabilities rather than generated text. Use +`wally decisions` (alias `wally decide`), not `wally run` or a coding harness. +It supports yes/no (`--ask`), choices (`--choice`) and ordered levels +(`--score`); `wally decisions --help` shows the request-file and JSON forms. + +`eve` is the hosted decision model and the default, so `-m` is optional. Two +local checkpoints run the same questions on-device: + +| Id | Backend | Pull | +|---|---|---| +| `clef-flash-9b` | llama.cpp (Q4_K_M) | `wally models pull clef-flash-9b` | +| `clef-flash-mlx` | MLX (Apple) | `wally models pull clef-flash-mlx` | + +`wally decisions --local -m ` forces the local path and never touches the +network; a bare `-m ` selects it automatically. On Apple, MLX +scoring needs the Swift runtime release that carries the decision callback; +the GGUF row runs on every kit today. diff --git a/install.ps1 b/install.ps1 index cee8ede9..e5e07042 100644 --- a/install.ps1 +++ b/install.ps1 @@ -43,6 +43,49 @@ function Fail([string]$Message) { throw "Error: $Message" } +# Replaces an existing install file by file. Windows will not move a folder +# whose wally.exe or DLLs are in use (`wally update` runs this installer while +# wally itself is running, and another terminal may be running wally too), but +# it will rename each of those files. So every current file is renamed to +# .old, the new files are moved in beside them, and any failure renames +# them all back. A .old file still in use cannot be deleted yet; the next +# update, or the next wally run, removes it. +function Update-InPlace([string]$Source, [string]$Target) { + Get-ChildItem -LiteralPath $Target -Filter '*.old' -Force | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + $Retired = @() + $Placed = @() + try { + foreach ($Item in @(Get-ChildItem -LiteralPath $Target -Force)) { + $Aside = "$($Item.FullName).old" + if (Test-Path -LiteralPath $Aside) { $Aside = "$($Item.FullName).$PID.old" } + Rename-Item -LiteralPath $Item.FullName -NewName (Split-Path $Aside -Leaf) -ErrorAction Stop + $Retired += [pscustomobject]@{ Original = $Item.FullName; Aside = $Aside } + } + foreach ($Item in @(Get-ChildItem -LiteralPath $Source -Force)) { + $Destination = Join-Path $Target $Item.Name + Move-Item -LiteralPath $Item.FullName -Destination $Destination -ErrorAction Stop + $Placed += $Destination + } + } catch { + $Reason = $_.Exception.Message + foreach ($Path in $Placed) { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue } + foreach ($Entry in $Retired) { + Rename-Item -LiteralPath $Entry.Aside -NewName (Split-Path $Entry.Original -Leaf) -ErrorAction SilentlyContinue + } + Fail "Could not replace the installed files ($Reason); the existing installation was left unchanged." + } + foreach ($Entry in $Retired) { + Remove-Item -LiteralPath $Entry.Aside -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# Check mode: print the latest release version and nothing else, then stop +# without installing. `wally update` and its update-available notice read it. +# An environment variable rather than a flag, because `irm ... | iex` passes +# no arguments. +$CheckOnly = $env:WALLY_INSTALL_CHECK -eq '1' + # Two overrides, for release tests and mirrors rather than everyday use -- # the same shape and names as install.sh's: # WALLY_INSTALL_VERSION install that release instead of the latest @@ -60,12 +103,12 @@ if ($env:WALLY_INSTALL_VERSION) { Fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '$($env:WALLY_INSTALL_VERSION)'" } $Release = $null - Write-Info "Installing v$Version" + if (-not $CheckOnly) { Write-Info "Installing v$Version" } } else { if ($env:WALLY_INSTALL_BASE_URL) { Fail 'WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup' } - Write-Info 'Checking latest Wally release...' + if (-not $CheckOnly) { Write-Info 'Checking latest Wally release...' } try { $Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" } catch { @@ -73,7 +116,12 @@ if ($env:WALLY_INSTALL_VERSION) { } $Version = "$($Release.tag_name)" -replace '^v', '' if (-not $Version) { Fail 'Could not determine latest release version. Check your internet connection.' } - Write-Info "Latest version: v$Version" + if (-not $CheckOnly) { Write-Info "Latest version: v$Version" } +} + +if ($CheckOnly) { + Write-Output $Version + return } # PROCESSOR_ARCHITECTURE reports the process, not the machine, so a 32-bit host @@ -187,20 +235,11 @@ try { $InstallParent = Split-Path $InstallDir -Parent New-Item -ItemType Directory -Path $InstallParent -Force | Out-Null - $Backup = "$InstallDir.previous" - Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $InstallDir) { - Move-Item -LiteralPath $InstallDir -Destination $Backup - } - try { + Update-InPlace $Candidate $InstallDir + } else { Move-Item -LiteralPath $Candidate -Destination $InstallDir - } catch { - if (Test-Path -LiteralPath $Backup) { - Move-Item -LiteralPath $Backup -Destination $InstallDir - } - throw } - Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue } finally { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue } @@ -217,14 +256,18 @@ $Entries = @() if ($UserPath) { $Entries = @($UserPath -split ';' | Where-Object { $_ }) } if ($Entries -contains $InstallDir) { Write-Ok "$InstallDir is already on your PATH" + $PathChanged = $false } else { [Environment]::SetEnvironmentVariable('Path', (($Entries + $InstallDir) -join ';'), 'User') Write-Ok "Added $InstallDir to your PATH" + $PathChanged = $true } Write-Ok "Wally v$Version installed successfully" Write-Host '' -Write-Warn 'Open a new terminal before running wally. This one was started with the old PATH.' +if ($PathChanged) { + Write-Warn 'Open a new terminal before running wally. This one was started with the old PATH.' +} Write-Host '' Write-Info 'Getting started:' Write-Host ' wally models list --all every model in the catalog' diff --git a/install.sh b/install.sh index 14dd94a0..e1cca9b1 100755 --- a/install.sh +++ b/install.sh @@ -322,16 +322,41 @@ recover_interrupted_install() { return 0 } +# Sets VERSION to the release to install: WALLY_INSTALL_VERSION when given, +# otherwise the latest GitHub release. +resolve_version() { +if [ -n "${WALLY_INSTALL_VERSION:-}" ]; then + VERSION="${WALLY_INSTALL_VERSION#v}" + printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '${WALLY_INSTALL_VERSION}'" +else + [ -z "${WALLY_INSTALL_BASE_URL:-}" ] \ + || fail "WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup" + # shellcheck disable=SC2086 # CURL_RETRY is two options, split on purpose + latest=$(curl -fsSL $CURL_RETRY "https://api.github.com/repos/${REPO}/releases/latest") \ + || fail "Could not determine latest release version. Check your internet connection." + VERSION=$(printf '%s\n' "$latest" \ + | grep '"tag_name"' \ + | sed 's/.*"v\([^"]*\)".*/\1/') +fi +[ -n "$VERSION" ] || fail "Could not determine latest release version. Check your internet connection." +} + main() { # --- arguments -------------------------------------------------------------- # The version the caller already has, passed by `wally update` so the script can # tell it apart from a fresh install and skip the download when nothing is newer. CURRENT_VERSION="" +CHECK_ONLY="" for arg in "$@"; do case "$arg" in nightly|--nightly) fail "nightly/dev installs are no longer published; this installer only supports production releases" ;; --version=*) CURRENT_VERSION="${arg#--version=}" ;; + # Print the latest release version on stdout and nothing else, then + # exit without installing. `wally update --check-for-updates` and the + # update-available notice both read it. + --check) CHECK_ONLY=1 ;; # Debug-only: print the resolved skill targets and exit before any # network work. Exercised by scripts/test/test-install-skill-dirs.sh. --print-skill-dirs) skill_target_dirs; exit 0 ;; @@ -351,27 +376,19 @@ for arg in "$@"; do esac done +if [ -n "$CHECK_ONLY" ]; then + resolve_version + printf '%s\n' "$VERSION" + exit 0 +fi + CHANNEL="production" banner printf ' %sInstalling the %s%s%s build%s\n\n' "$DIM" "$R$B" "$CHANNEL" "$R$DIM" "$R" step "Resolving the latest release" -if [ -n "${WALLY_INSTALL_VERSION:-}" ]; then - VERSION="${WALLY_INSTALL_VERSION#v}" - printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ - || fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '${WALLY_INSTALL_VERSION}'" -else - [ -z "${WALLY_INSTALL_BASE_URL:-}" ] \ - || fail "WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup" - # shellcheck disable=SC2086 # CURL_RETRY is two options, split on purpose - latest=$(curl -fsSL $CURL_RETRY "https://api.github.com/repos/${REPO}/releases/latest") \ - || fail "Could not determine latest release version. Check your internet connection." - VERSION=$(printf '%s\n' "$latest" \ - | grep '"tag_name"' \ - | sed 's/.*"v\([^"]*\)".*/\1/') -fi -[ -n "$VERSION" ] || fail "Could not determine latest release version. Check your internet connection." +resolve_version ok "v${VERSION}" # An update check: the caller told us its version. If nothing newer is out, diff --git a/scripts/build/fetch-kit.sh b/scripts/build/fetch-kit.sh index ee58184b..e81f64c8 100755 --- a/scripts/build/fetch-kit.sh +++ b/scripts/build/fetch-kit.sh @@ -3,7 +3,8 @@ # # scripts/fetch-kit.sh # -# Requires: gh, and either shasum or sha256sum. +# Requires: curl or wget, and either shasum or sha256sum. The kit lives on a +# public GitHub release, so plain HTTPS works; `gh` is only a fallback. # Pins live in versions.toml (the single source; cmake/sdk-pin.cmake reads the # same file). SDK_VERSION, if set, must equal kit_version there -- checksums are # keyed to that pin, not a repo variable. @@ -37,22 +38,33 @@ SDK_VERSION="$PINNED_SDK" RELEASE_TAG="$(wally_kit_release_tag)" asset="RunAnywhere-cpp-desktop-${PLATFORM}-v${SDK_VERSION}.tar.gz" +url="https://github.com/RunanywhereAI/runanywhere-sdks/releases/download/${RELEASE_TAG}/${asset}" dl="$(mktemp -d)" +file="${dl}/${asset}" trap 'rm -rf "$dl"' EXIT -# Draft GitHub Releases are invisible to another repo's GITHUB_TOKEN -# (`release not found`). The pin must point at a published release -# (prerelease is fine; latest is not required). -if ! gh release download "$RELEASE_TAG" \ - --repo RunanywhereAI/runanywhere-sdks \ - --pattern "$asset" --dir "$dl"; then +# Draft GitHub Releases are invisible even to a signed-in token (`release not +# found`); the pin must point at a published release. Plain HTTPS first -- no +# auth, no gh -- then gh as fallback for networks or tokens that need it. Each +# attempt writes the same path, so a partial file from a failed attempt is +# dropped before the next, and gh gets --clobber to overwrite a leftover. +dl_ok="" +if curl -fSL --retry 3 -o "$file" "$url"; then + dl_ok=1 +elif rm -f "$file" && wget -q -O "$file" "$url"; then + dl_ok=1 +elif rm -f "$file" && gh release download "$RELEASE_TAG" \ + --repo RunanywhereAI/runanywhere-sdks \ + --pattern "$asset" --dir "$dl" --clobber; then + dl_ok=1 +fi +if [[ -z "$dl_ok" ]]; then echo "error: could not download $asset from RunanywhereAI/runanywhere-sdks@${RELEASE_TAG}" >&2 echo " that tag must be a published GitHub Release (drafts 404 for this token)." >&2 gh release view "$RELEASE_TAG" --repo RunanywhereAI/runanywhere-sdks >&2 || true exit 1 fi -file="${dl}/${asset}" if command -v shasum >/dev/null 2>&1; then actual="$(shasum -a 256 "$file" | awk '{print $1}')" else diff --git a/scripts/build/package-wally-windows.ps1 b/scripts/build/package-wally-windows.ps1 index 623b0f25..fdbc2365 100644 --- a/scripts/build/package-wally-windows.ps1 +++ b/scripts/build/package-wally-windows.ps1 @@ -13,15 +13,8 @@ param( # that spelling or the native archive is never found. [Parameter(Mandatory = $false)] [ValidateSet("windows-x86_64", "windows-arm64")] - [string]$Platform = "windows-x86_64", - - # "prod" for the production bottle, "dev" for the dev-endpoint bottle. Only - # the archive filename changes (-dev); the staged tree stays wally-. - [Parameter(Mandatory = $false)] - [ValidateSet("prod", "dev")] - [string]$Channel = "prod" + [string]$Platform = "windows-x86_64" ) -$Suffix = if ($Channel -eq "dev") { "-dev" } else { "" } $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest @@ -66,7 +59,7 @@ $DistDir = Join-Path $CliRoot "dist" $StageRoot = Join-Path $DistDir "stage" $Stage = Join-Path $StageRoot "wally-$Platform" $BinDir = Join-Path $Stage "bin" -$Zip = Join-Path $DistDir "wally-$Version-$Platform$Suffix.zip" +$Zip = Join-Path $DistDir "wally-$Version-$Platform.zip" Remove-Item $Stage -Recurse -Force -ErrorAction SilentlyContinue New-Item $BinDir -ItemType Directory -Force | Out-Null @@ -94,11 +87,11 @@ try { & (Join-Path $BinDir "wally.exe") version if ($LASTEXITCODE -ne 0) { throw "packaged wally version smoke failed" } - # The archive name says which flavour this is; the binary has to agree. A dev - # job whose endpoint variables were unset used to produce a `-dev` archive - # that defaults to production, and nothing noticed (wally #87). Asked in an - # empty profile with the runtime overrides cleared, so a signed-in account or - # a stray WALLY_CONSOLE_URL on the build machine cannot answer for the bake. + # A binary that resolves no console cannot sign anyone in. Configure requires the + # endpoints (CMakeLists.txt); CI discards them for fork pull requests, so this + # is only reachable for an archive built that way. Asked in an empty profile + # with the runtime overrides cleared, so a signed-in account or a stray + # WALLY_CONSOLE_URL on the build machine cannot answer for the build. $ProbeProfile = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) New-Item $ProbeProfile -ItemType Directory -Force | Out-Null $SavedEnv = @{} @@ -118,12 +111,10 @@ try { } Remove-Item $ProbeProfile -Recurse -Force -ErrorAction SilentlyContinue } - $BuiltChannel = ([regex]::Match(($About -join ""), '"channel":"([^"]*)"')).Groups[1].Value - $WantChannel = if ($Channel -eq "dev") { "development" } else { "production" } - if ($BuiltChannel -ne $WantChannel) { - throw ("packaging a '$Channel' archive from a '$BuiltChannel' binary; expected " + - "channel '$WantChannel'. Set WALLY_CHANNEL and the baked endpoint variables " + - "in the configure environment, or package the matching build.") + $BuiltConsole = ([regex]::Match(($About -join ""), '"console":"([^"]*)"')).Groups[1].Value + if ([string]::IsNullOrWhiteSpace($BuiltConsole)) { + throw ("this archive resolves no console; it was configured without " + + "WALLY_BAKED_CONSOLE_API_URL / WALLY_BAKED_CONSOLE_WEB_ORIGIN.") } } finally { $env:PATH = $OldPath diff --git a/scripts/build/package-wally.sh b/scripts/build/package-wally.sh index e21f55a1..b08bafcb 100755 --- a/scripts/build/package-wally.sh +++ b/scripts/build/package-wally.sh @@ -17,14 +17,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # shellcheck source=scripts/lib/common.sh source "${ROOT}/scripts/lib/common.sh" -BUILD="${1:?usage: package-wally.sh [channel]}" -PLATFORM="${2:?usage: package-wally.sh [channel]}" -# channel: empty/prod for the production bottle, "dev" for the dev-endpoint -# bottle. Only the archive filename changes (-dev); the staged tree and its -# single root stay wally- so install scripts extract both the same. -CHANNEL="${3:-}" -SUFFIX="" -[[ "${CHANNEL}" == dev ]] && SUFFIX="-dev" +BUILD="${1:?usage: package-wally.sh }" +PLATFORM="${2:?usage: package-wally.sh }" [[ "${BUILD}" = /* ]] || BUILD="${ROOT}/${BUILD}" VERSION="${WALLY_VERSION:-$(wally_version)}" @@ -53,7 +47,7 @@ fi DIST="${ROOT}/dist" STAGE_ROOT="${DIST}/stage" STAGE="${STAGE_ROOT}/wally-${PLATFORM}" -TARBALL="${DIST}/wally-${VERSION}-${PLATFORM}${SUFFIX}.tar.gz" +TARBALL="${DIST}/wally-${VERSION}-${PLATFORM}.tar.gz" rm -rf "${STAGE}" mkdir -p "${STAGE}/bin" "${STAGE}/lib" @@ -223,8 +217,8 @@ smoke_out="$("${STAGE}/bin/wally" version 2>&1)" || smoke_rc=$? if [ "${smoke_rc}" -ne 0 ]; then if printf '%s' "${smoke_out}" | grep -qE "version .(GLIBC|GLIBCXX|CXXABI)_[0-9]"; then echo "note: this build host cannot start the bottle (its floor is newer than" >&2 - echo " the host glibc/libstdc++); skipping the run-time smoke and channel" >&2 - echo " checks. check-linux-abi.py and the install matrix cover the target." >&2 + echo " the host glibc/libstdc++); skipping the run-time smoke. check-linux-abi.py" >&2 + echo " and the install matrix cover the target." >&2 skip_runtime_checks=1 else echo "error: wally failed to start during packaging:" >&2 @@ -233,29 +227,21 @@ if [ "${smoke_rc}" -ne 0 ]; then fi fi -# The archive name says which flavour this is; the binary has to agree. A dev -# job whose endpoint variables were unset used to produce a `-dev` archive that -# defaults to production, and nothing anywhere noticed (wally #87). -# -# Asked in an empty profile with the runtime overrides cleared, so a signed-in -# account or a stray WALLY_CONSOLE_URL on the build machine cannot answer for -# the bake. +# A binary that resolves no console cannot sign anyone in. Configure requires +# the endpoints (CMakeLists.txt); CI discards them for fork pull requests, so +# this is only reachable for a bottle built that way. Asked in an empty profile +# with the runtime overrides cleared, so a signed-in account or a stray +# WALLY_CONSOLE_URL on the build machine cannot answer for the build. if [ "${skip_runtime_checks}" -ne 1 ]; then probe_profile="$(mktemp -d)" about="$(env -u WALLY_CONSOLE_URL -u WALLY_CONSOLE_WEB_URL -u RCLI_CONSOLE_URL \ -u RCLI_CONSOLE_WEB_URL WALLY_PROFILE_DIR="${probe_profile}" \ "${STAGE}/bin/wally" about --json)" rm -rf "${probe_profile}" - built_channel="$(printf '%s' "$about" | sed -nE 's/.*"channel":"([^"]*)".*/\1/p')" - case "${CHANNEL}" in - dev) want_channel="development" ;; - *) want_channel="production" ;; - esac - if [ "${built_channel}" != "${want_channel}" ]; then - echo "error: packaging a '${CHANNEL:-prod}' archive from a '${built_channel}' binary." >&2 - echo " Expected channel '${want_channel}'. Set WALLY_CHANNEL and the baked" >&2 - echo " endpoint variables in the configure environment, or package the" >&2 - echo " matching build." >&2 + built_console="$(printf '%s' "$about" | sed -nE 's/.*"console":"([^"]*)".*/\1/p')" + if [ -z "${built_console}" ]; then + echo "error: this bottle resolves no console; it was configured without" >&2 + echo " WALLY_BAKED_CONSOLE_API_URL / WALLY_BAKED_CONSOLE_WEB_ORIGIN." >&2 exit 1 fi fi diff --git a/scripts/build/sys-bindings.h b/scripts/build/sys-bindings.h index b52bf7ac..96b9a2d0 100644 --- a/scripts/build/sys-bindings.h +++ b/scripts/build/sys-bindings.h @@ -13,6 +13,9 @@ #include "rac/core/rac_sdk_state.h" #include "rac/core/rac_types.h" #include "rac/desktop/rac_desktop.h" +#include "rac/features/decision/rac_decision_component.h" +#include "rac/features/decision/rac_decision_service.h" +#include "rac/features/decision/rac_decision_types.h" #include "rac/features/diarization/rac_diarization_service.h" #include "rac/features/diarization/rac_diarization_types.h" #include "rac/features/diffusion/rac_diffusion_service.h" diff --git a/scripts/test/smoke-mlx.sh b/scripts/test/smoke-mlx.sh index 17fbc139..3cd12859 100755 --- a/scripts/test/smoke-mlx.sh +++ b/scripts/test/smoke-mlx.sh @@ -112,4 +112,23 @@ echo "VLM: SKIP (vlm disabled for llm-only cut)" # require_text "VLM" "$vlm_out" # printf '%s\n' "$vlm_out" +# Local decision scoring. Gated on the checkpoint being present or pullable; +# the GGUF row is the transport every kit can run, so it is the one exercised. +# Set WALLY_SMOKE_DECISION=0 to skip. This is the only place a real model +# proves `wally decisions --local` end-to-end. +DECISION_GGUF="${WALLY_SMOKE_DECISION_GGUF:-clef-flash-9b}" +if [[ "${WALLY_SMOKE_DECISION:-1}" == "1" ]]; then + echo "Decision (local GGUF): $DECISION_GGUF" + pull_if_enabled "$DECISION_GGUF" + decision_out="$(wally decisions --local -m "$DECISION_GGUF" \ + --input "I was charged twice. Please refund the extra payment." \ + --choice "Team=billing,technical,other" \ + --ask "Is this a refund request?" \ + --score "Urgency=low,medium,high")" + require_text "Decision" "$decision_out" + printf '%s\n' "$decision_out" +else + echo "Decision: SKIP (WALLY_SMOKE_DECISION=0)" +fi + echo "smoke-mlx: ok (tts/stt/vlm skipped for llm-only cut)" diff --git a/src/account/console.rs b/src/account/console.rs index c7da1c08..d3452856 100644 --- a/src/account/console.rs +++ b/src/account/console.rs @@ -256,6 +256,7 @@ pub struct CatalogPrice { pub id: String, pub input_per_mtok: i64, pub output_per_mtok: i64, + pub cached_input_per_mtok: i64, } #[derive(Debug, Clone, Default, PartialEq)] @@ -1216,10 +1217,16 @@ impl ConsoleClient { && refusal.as_ref().and_then(|error| error.code) == Some(decisions::ErrorCode::KModelNotEntitled) { + // Entitlement is account-level, not session-level: the token was + // just accepted (a bad one answers 401, which `call` refreshes + // and retries before ever reaching here), so telling the person + // to log in again sends them on a trip that cannot change the + // answer. Point at what can: the model is unavailable on THIS + // account, and the local checkpoints don't need entitlement. return failed(format!( - "your session is not entitled to {}; sign in again with `wally account logout` \ - and `wally account login`, then retry", - request_body.model + "{} is not available on this account (it is not enabled for it yet); \ + use a local model — `wally decisions --local -m clef-flash-9b`", + request_body.model, )); } // A refused request (an input over the model's window, a model that @@ -1914,6 +1921,17 @@ impl ConsoleClient { &self, console_url: &str, access_token: &str, + ) -> (IdentityResult, Vec, String) { + self.fetch_models_within(console_url, access_token, 0) + } + + /// `fetch_models` bounded to `timeout_ms` in all (0: the transport's + /// defaults), for a caller that has something else to show if it fails. + pub fn fetch_models_within( + &self, + console_url: &str, + access_token: &str, + timeout_ms: i32, ) -> (IdentityResult, Vec, String) { if !super::session_token_is_safe(access_token) { return ( @@ -1931,7 +1949,7 @@ impl ConsoleClient { url: format!("{origin}/v1/models"), body: String::new(), bearer_token: access_token.to_string(), - timeout_ms: 0, + timeout_ms, }; let response = match self.send(request) { Ok(response) => response, @@ -2039,6 +2057,7 @@ impl ConsoleClient { id: model.id, input_per_mtok: model.input_per_mtok, output_per_mtok: model.output_per_mtok, + cached_input_per_mtok: model.cached_input_per_mtok, }) .collect(); (IdentityResult::Ok, prices, String::new()) diff --git a/src/account/credentials.rs b/src/account/credentials.rs index f0f0bb22..3ce13b1f 100644 --- a/src/account/credentials.rs +++ b/src/account/credentials.rs @@ -54,44 +54,32 @@ impl Credentials { } } -// Two hosts, two jobs, two different deployments. Keep them adjacent: the bug -// they replace was one constant doing both, and it broke every command that -// talks to the console. -// -// The API is the control plane -- /auth/cli/start, /auth/cli/poll, -// /auth/cli/refresh, /v1/me, /v1/cli/* -- and it is served by Cloud Run. The web -// console is the page a person approves a sign-in on, and it is served by -// Railway. Measured 2026-09-04: -// -// inference.runanywhere.ai /auth/cli/start 422 /v1/me 405 Google Frontend -// console.runanywhere.ai /auth/cli/start 404 /v1/me 404 railway-hikari -// -// 422 and 405 are those endpoints rejecting a bad body and a wrong verb, which -// is how you know they are there. 404 with the console's own SPA HTML is how -// you know they are not. Point the API constant at the Railway host -- which is -// what it used to be -- and login, whoami and usage all 404. -const PRODUCTION_CONSOLE_API: &str = "https://inference.runanywhere.ai"; - -// The approval page, under both names it answers to. One deployment: measured -// 2026-09-04, both return `server: railway-hikari` and the same `etag: -// "nqkdmw"` for /cloud/cli, so this is two DNS names for one build and not two -// origins' worth of trust. -// -// The raw Railway name is here because it is what the control plane actually -// hands out today: -// -// POST https://inference.runanywhere.ai/auth/cli/start -// -> verification_url: https://runanywhere-frontend-production.up.railway.app/cloud/cli?code=... -// -// Trusting only the custom domain would refuse every real sign-in. Delete the -// Railway entry once the control plane's configured console origin is the -// custom domain -- that is a one-line config change on the API side, and this -// list is the thing waiting on it. -const PRODUCTION_CONSOLE_WEB: [&str; 2] = [ - "https://console.runanywhere.ai", - "https://runanywhere-frontend-production.up.railway.app", +// The console this build talks to. Both values are required configure inputs +// (CMakeLists.txt): the API is the control plane -- /auth/cli/start, +// /auth/cli/poll, /auth/cli/refresh, /v1/me, /v1/cli/* -- and the web origin is +// the page a person approves a sign-in on. They live in the build tree, never +// in source, so this binary's endpoints are a deployment fact rather than a +// constant in the repository. A CI build with no repository configuration (a +// fork pull request) compiles both empty; `WALLY_CONSOLE_URL` still names a +// console at runtime, and `wally about --json` reports an empty default. +const CONSOLE_API: &str = env!("WALLY_CONSOLE_API_URL"); + +// Every browser origin allowed to host the approval page. One is required; the +// second is optional, for a deployment the control plane hands out under a name +// of its own while the custom domain is also live. +const CONSOLE_WEB: [&str; 2] = [ + env!("WALLY_CONSOLE_WEB_ORIGIN"), + env!("WALLY_CONSOLE_WEB_ORIGIN_ALT"), ]; +fn console_web_origins() -> Vec<&'static str> { + CONSOLE_WEB + .iter() + .copied() + .filter(|s| !s.is_empty()) + .collect() +} + const MAXIMUM_CREDENTIAL_BYTES: u64 = 1024 * 1024; #[cfg(windows)] @@ -130,13 +118,13 @@ struct ParsedUrl { /// Reduces a console URL's path to the prefix every endpoint hangs off, or /// fails if it is not one. /// -/// A console is not always at the root of its host. Development is reached at -/// `https://inference.runanywhere.ai/api-dev`, where the load balancer strips -/// the prefix and forwards to the dev control plane; the same host without it -/// is production. Refusing the path -- which this did until it was found -- -/// leaves no way to name the dev console at all, so `--console-url` had to be -/// given the backend's own Cloud Run hostname, which bypasses the load balancer -/// and therefore reaches different code than any real client does. +/// A console is not always at the root of its host. A deployment can be reached +/// at `https://host/prefix`, where the load balancer strips the prefix and +/// forwards to the control plane behind it. Refusing the path -- which this did +/// until it was found -- leaves no way to name that console at all, and +/// `--console-url` had to be given the backend's own Cloud Run hostname, which +/// bypasses the load balancer and therefore reaches different code than any +/// real client does. /// /// A query or fragment is still refused. Every caller builds an endpoint by /// appending to this string, and `?a=1` + `/v1/me` is not a URL. @@ -892,23 +880,25 @@ pub fn default_console_url() -> String { if !configured.is_empty() { return configured; } - // A dev build carries its control plane compiled in (see - // baked_endpoints.h.in): the env override above still wins, production - // builds generate an empty macro and fall through unchanged. - let baked = env!("WALLY_BAKED_CONSOLE_API_URL"); - if !baked.is_empty() { - return baked.to_string(); - } - PRODUCTION_CONSOLE_API.to_string() + CONSOLE_API.to_string() } -/// The baked development control plane, normalised, or empty when none baked. -pub fn baked_console_api_url() -> String { - let baked = env!("WALLY_BAKED_CONSOLE_API_URL"); - if baked.is_empty() { +/// The control plane this build was configured against, normalised, or empty +/// when it was configured with none (a CI build without repository config). +pub fn configured_console_api_url() -> String { + if CONSOLE_API.is_empty() { return String::new(); } - normalize_console_url(baked).unwrap_or_default() + normalize_console_url(CONSOLE_API).unwrap_or_default() +} + +/// The approval origins this build was configured against, normalised, in +/// configure order. Empty when the build was configured with none. +pub fn configured_console_web_origins() -> Vec { + console_web_origins() + .into_iter() + .filter_map(|origin| normalize_console_url(origin).ok()) + .collect() } /// The browser origins allowed to host the approval page for `console_url`. @@ -922,21 +912,20 @@ pub fn trusted_browser_origins(console_url: &str) -> Vec { return vec![normalized]; } } - if console_url == PRODUCTION_CONSOLE_API { - return PRODUCTION_CONSOLE_WEB - .iter() - .map(|s| s.to_string()) + // Held pairwise: the configured web origins are honored only while talking + // to the configured API, never for an arbitrary --base-url. Trusting them + // for any console would let an attacker-controlled API redirect a sign-in + // to a page we would then accept a code from. + if !CONSOLE_API.is_empty() && console_url == CONSOLE_API { + let mut origins: Vec = console_web_origins() + .into_iter() + .filter_map(|origin| normalize_console_url(origin).ok()) .collect(); - } - // A dev build that baked its control plane also baked which browser - // console may approve sign-ins for it (a local console on loopback, - // typically). Trust holds pairwise: the baked web origin is honored only - // while talking to the baked API, never for an arbitrary --base-url. - let baked_web = env!("WALLY_BAKED_CONSOLE_WEB_ORIGIN"); - let baked_api = baked_console_api_url(); - if !baked_web.is_empty() && !baked_api.is_empty() && console_url == baked_api { - if let Ok(normalized) = normalize_console_url(baked_web) { - return vec![normalized, console_url.to_string()]; + if !origins.is_empty() { + if !origins.iter().any(|origin| origin == console_url) { + origins.push(console_url.to_string()); + } + return origins; } } // Anything else -- a dev console, a loopback stub -- is trusted only at its @@ -956,14 +945,12 @@ pub fn effective_console_web_origin(console_url: &str) -> String { return normalized; } } - if console_url == PRODUCTION_CONSOLE_API { - return PRODUCTION_CONSOLE_WEB[0].to_string(); - } - let baked_web = env!("WALLY_BAKED_CONSOLE_WEB_ORIGIN"); - let baked_api = baked_console_api_url(); - if !baked_web.is_empty() && !baked_api.is_empty() && console_url == baked_api { - if let Ok(normalized) = normalize_console_url(baked_web) { - return normalized; + if !CONSOLE_API.is_empty() && console_url == CONSOLE_API { + if let Some(origin) = console_web_origins() + .into_iter() + .find_map(|origin| normalize_console_url(origin).ok()) + { + return origin; } } console_url.to_string() diff --git a/src/anthropic/messages.rs b/src/anthropic/messages.rs index 34965f69..9c85ed08 100644 --- a/src/anthropic/messages.rs +++ b/src/anthropic/messages.rs @@ -1353,16 +1353,13 @@ mod tests { Some(("http://127.0.0.1:8080".to_string(), "/v1".to_string())) ); assert_eq!( - split_base_url("https://inference.runanywhere.ai"), - Some(( - "https://inference.runanywhere.ai".to_string(), - String::new() - )) + split_base_url("https://api.example.test"), + Some(("https://api.example.test".to_string(), String::new())) ); assert_eq!( - split_base_url("https://inference.runanywhere.ai/api-dev"), + split_base_url("https://api.example.test/api-dev"), Some(( - "https://inference.runanywhere.ai".to_string(), + "https://api.example.test".to_string(), "/api-dev".to_string() )) ); diff --git a/src/anthropic/translate.rs b/src/anthropic/translate.rs index 1e7c3020..6829d36b 100644 --- a/src/anthropic/translate.rs +++ b/src/anthropic/translate.rs @@ -22,6 +22,8 @@ pub struct StreamState { pub opened: bool, pub block_open: bool, pub text_index: i32, + pub thinking_open: bool, + pub thinking_index: i32, pub next_index: i32, pub failed: bool, pub closed: bool, @@ -30,6 +32,9 @@ pub struct StreamState { pub stop_reason: String, pub input_tokens: i32, pub output_tokens: i32, + /// `prompt_tokens_details.cached_tokens` when upstream reported it; None + /// keeps the usage block exactly as it was before cache reporting. + pub cached_tokens: Option, pub input_estimate: i32, pub output_chars: i32, pub tool_calls: BTreeMap, @@ -44,6 +49,8 @@ impl Default for StreamState { opened: false, block_open: false, text_index: -1, + thinking_open: false, + thinking_index: -1, next_index: 0, failed: false, closed: false, @@ -52,6 +59,7 @@ impl Default for StreamState { stop_reason: String::new(), input_tokens: 0, output_tokens: 0, + cached_tokens: None, input_estimate: 0, output_chars: 0, tool_calls: BTreeMap::new(), @@ -277,6 +285,62 @@ fn estimate_tokens_from_chars(chars: usize) -> i32 { } } +/// `usage.prompt_tokens_details.cached_tokens`, when upstream sent it. +fn cached_tokens(usage: &Value) -> Option { + usage + .get("prompt_tokens_details") + .and_then(|details| details.get("cached_tokens")) + .and_then(Value::as_i64) + .map(|v| v as i32) +} + +/// Anthropic usage from OpenAI counts. Anthropic counts cache reads apart +/// from `input_tokens` while OpenAI's `prompt_tokens` includes them, so the +/// cached part moves to `cache_read_input_tokens` and the total is unchanged. +fn anthropic_usage(prompt: i32, output: i32, cached: Option) -> Value { + match cached { + Some(cached) => { + let cached = cached.clamp(0, prompt.max(0)); + json!({ + "input_tokens": prompt - cached, + "output_tokens": output, + "cache_read_input_tokens": cached, + }) + } + None => json!({"input_tokens": prompt, "output_tokens": output}), + } +} + +fn read_stream_usage(usage: &Value, state: &mut StreamState) { + state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); + state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + if let Some(cached) = cached_tokens(usage) { + state.cached_tokens = Some(cached); + } +} + +fn close_text_block(state: &mut StreamState) -> String { + if !state.block_open { + return String::new(); + } + state.block_open = false; + event( + "content_block_stop", + &json!({"type": "content_block_stop", "index": state.text_index}), + ) +} + +fn close_thinking_block(state: &mut StreamState) -> String { + if !state.thinking_open { + return String::new(); + } + state.thinking_open = false; + event( + "content_block_stop", + &json!({"type": "content_block_stop", "index": state.thinking_index}), + ) +} + // ---- public API ------------------------------------------------------- /// One Anthropic `/v1/messages` request body translated to OpenAI @@ -457,9 +521,17 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { .to_string(); let mut content = Vec::new(); + // The model's reasoning, shown the way Anthropic shows its own. The + // signature is empty: no upstream here signs its reasoning, and the + // block is dropped again when Claude Code sends the history back. + let reasoning = field(&message, "reasoning_content"); + if !reasoning.is_empty() { + content.push(json!({"type": "thinking", "thinking": reasoning, "signature": ""})); + } if !text.is_empty() { content.push(json!({"type": "text", "text": text})); } + let blocks_before_tools = content.len(); if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) { for call in calls { if !call.is_object() { @@ -482,7 +554,7 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { })); } } - let tool_called = content.len() > if text.is_empty() { 0 } else { 1 }; + let tool_called = content.len() > blocks_before_tools; if content.is_empty() { content.push(json!({"type": "text", "text": ""})); } @@ -503,10 +575,11 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { "content": content, "stop_reason": if stop.is_empty() { Value::Null } else { json!(stop) }, "stop_sequence": Value::Null, - "usage": { - "input_tokens": count(&usage, "prompt_tokens", 0), - "output_tokens": count(&usage, "completion_tokens", 0), - }, + "usage": anthropic_usage( + count(&usage, "prompt_tokens", 0), + count(&usage, "completion_tokens", 0), + cached_tokens(&usage), + ), }) } @@ -609,8 +682,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri ); } if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) { - state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); - state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + read_stream_usage(usage, state); } return out; } @@ -629,8 +701,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) { - state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); - state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + read_stream_usage(usage, state); } if let Some(d) = choice.get("delta") { @@ -732,10 +803,38 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } } - // Reasoning tokens count toward the output estimate but never surface as - // their own block; Anthropic has no equivalent block type here. - if let Some(reasoning) = delta.get("reasoning_content").and_then(Value::as_str) { + // Reasoning streams as a thinking block, the way Anthropic streams its + // own. Blocks never overlap: a thinking delta closes an open text block, + // and text closes an open thinking block, each opening at the next index. + // The signature is empty; see response_to_anthropic. + if let Some(reasoning) = delta + .get("reasoning_content") + .and_then(Value::as_str) + .filter(|r| !r.is_empty()) + { state.output_chars += reasoning.len() as i32; + out += &close_text_block(state); + if !state.thinking_open { + state.thinking_open = true; + state.thinking_index = state.next_index; + state.next_index += 1; + out += &event( + "content_block_start", + &json!({ + "type": "content_block_start", + "index": state.thinking_index, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + }), + ); + } + out += &event( + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": state.thinking_index, + "delta": {"type": "thinking_delta", "thinking": reasoning}, + }), + ); } let text = delta @@ -748,6 +847,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } state.output_chars += text.len() as i32; + out += &close_thinking_block(state); if !state.block_open { state.block_open = true; state.text_index = state.next_index; @@ -810,14 +910,8 @@ pub fn stream_close_to_anthropic(state: &mut StreamState) -> String { } state.closed = true; - let mut out = String::new(); - if state.block_open { - state.block_open = false; - out += &event( - "content_block_stop", - &json!({"type": "content_block_stop", "index": state.text_index}), - ); - } + let mut out = close_thinking_block(state); + out += &close_text_block(state); let mut index = state.next_index; let mut emitted_tool_block = false; @@ -867,7 +961,7 @@ pub fn stream_close_to_anthropic(state: &mut StreamState) -> String { &json!({ "type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": Value::Null}, - "usage": {"input_tokens": input_final, "output_tokens": output_final}, + "usage": anthropic_usage(input_final, output_final, state.cached_tokens), }), ); out += &event("message_stop", &json!({"type": "message_stop"})); @@ -1108,4 +1202,128 @@ mod tests { assert!(out.contains("\"model\":\"claude-3-5-sonnet-20241022\"")); assert_eq!(state.model, "claude-3-5-sonnet-20241022"); } + + fn chunk(delta: Value, finish: Value) -> Value { + json!({"id": "c1", "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}) + } + + fn streamed(chunks: &[Value]) -> String { + let mut state = StreamState::new(); + state.model = "m".to_string(); + let mut out = String::new(); + for c in chunks { + out += &stream_chunk_to_anthropic(c, &mut state); + } + out + &stream_close_to_anthropic(&mut state) + } + + /// The SSE stream as (event type, index, block or delta type) rows. + fn shape(sse: &str) -> Vec<(String, i64, String)> { + sse.lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|data| serde_json::from_str::(data).unwrap()) + .map(|e| { + let kind = e["content_block"]["type"] + .as_str() + .or(e["delta"]["type"].as_str()) + .unwrap_or("") + .to_string(); + ( + e["type"].as_str().unwrap().to_string(), + e["index"].as_i64().unwrap_or(-1), + kind, + ) + }) + .collect() + } + + fn row(event: &str, index: i64, kind: &str) -> (String, i64, String) { + (event.to_string(), index, kind.to_string()) + } + + #[test] + fn reasoning_then_text_streams_a_thinking_block_then_a_text_block() { + let out = streamed(&[ + chunk(json!({"reasoning_content": "let me "}), Value::Null), + chunk(json!({"reasoning_content": "check"}), Value::Null), + chunk(json!({"content": "51"}), json!("stop")), + ]); + let rows = shape(&out); + assert_eq!( + rows[1..7], + [ + row("content_block_start", 0, "thinking"), + row("content_block_delta", 0, "thinking_delta"), + row("content_block_delta", 0, "thinking_delta"), + row("content_block_stop", 0, ""), + row("content_block_start", 1, "text"), + row("content_block_delta", 1, "text_delta"), + ] + ); + assert_eq!(rows[7], row("content_block_stop", 1, "")); + assert!(out.contains(r#""thinking":"let me ""#)); + } + + #[test] + fn reasoning_only_closes_the_thinking_block_and_opens_no_text_block() { + let out = streamed(&[ + chunk(json!({"reasoning_content": "hmm"}), Value::Null), + chunk(json!({}), json!("length")), + ]); + let rows = shape(&out); + assert!(rows.contains(&row("content_block_stop", 0, ""))); + assert!(!rows.iter().any(|r| r.2 == "text")); + assert!(out.contains(r#""stop_reason":"max_tokens""#)); + } + + #[test] + fn non_streaming_reasoning_becomes_a_thinking_block_before_the_text() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"reasoning_content": "think", "content": "51"}, "finish_reason": "stop"}]}), + "m", + ); + assert_eq!( + reply["content"], + json!([ + {"type": "thinking", "thinking": "think", "signature": ""}, + {"type": "text", "text": "51"}, + ]) + ); + assert_eq!(reply["stop_reason"], json!("end_turn")); + } + + #[test] + fn cached_tokens_move_from_input_to_cache_read() { + let out = streamed(&[ + chunk(json!({"content": "hi"}), json!("stop")), + json!({"id": "c1", "choices": [], "usage": {"prompt_tokens": 3779, "completion_tokens": 9, "prompt_tokens_details": {"cached_tokens": 3776}}}), + ]); + assert!(out.contains( + r#""usage":{"cache_read_input_tokens":3776,"input_tokens":3,"output_tokens":9}"# + )); + } + + #[test] + fn absent_cached_tokens_leave_usage_as_it_was() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 2}}), + "m", + ); + assert_eq!( + reply["usage"], + json!({"input_tokens": 10, "output_tokens": 2}) + ); + } + + #[test] + fn zero_cached_tokens_report_a_zero_cache_read() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 2, "prompt_tokens_details": {"cached_tokens": 0}}}), + "m", + ); + assert_eq!( + reply["usage"], + json!({"input_tokens": 10, "output_tokens": 2, "cache_read_input_tokens": 0}) + ); + } } diff --git a/src/app.rs b/src/app.rs index 40de7e4d..140f8a39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -175,6 +175,36 @@ fn is_passthrough_command(token: &str) -> bool { NAMES.contains(&token) } +/// The commands that end with the update-available notice: every command on +/// the help page except `update` (it is the update) and `uninstall` (there is +/// nothing left to update). Help pages show it too, from run(). +fn shows_update_notice(path: &[String]) -> bool { + const NAMES: &[&str] = &["run", "serve", "models", "account", "about"]; + path.first() + .is_some_and(|name| NAMES.contains(&name.as_str()) || is_passthrough_command(name)) +} + +/// The update-available notice, unless `argv` asked for JSON or quiet output. +fn update_notice(argv: &[String], no_color_requested: bool) { + let flags = root_flags_before_passthrough(argv); + crate::commands::show_update_notice( + flags.contains(&"--json"), + flags.iter().any(|f| *f == "-q" || *f == "--quiet"), + no_color_requested, + ); +} + +/// wally's tokens on a parsed command line: everything before the `--` that +/// hands the rest to a wrapped tool, so the tool's own `--json` or `-q` is not +/// read as wally's. `argv` includes the program name at index 0. +fn root_flags_before_passthrough(argv: &[String]) -> Vec<&str> { + argv.iter() + .skip(1) + .take_while(|token| *token != "--") + .map(String::as_str) + .collect() +} + /// wally's own flags on those subcommands. `-m`/`--model` take a following /// value; the rest are booleans. The `=` forms carry their value inline. fn consumes_following_value(token: &str) -> bool { @@ -289,6 +319,7 @@ fn restore_stale_desktop_gateway(argv: &[String]) { /// Run wally with `args` (program name at index 0). Returns the exit code. pub fn run(args: &[String]) -> i32 { restore_stale_desktop_gateway(args); + crate::commands::sweep_replaced_files(); // `-u`/`-U` top-level shortcuts, guarded to the one shape that can't be // confused with a subcommand's own arguments: the entire command line is @@ -297,7 +328,7 @@ pub fn run(args: &[String]) -> i32 { if args.len() == 2 { let only_arg = args[1].as_str(); if only_arg == "-u" || only_arg == "--update" { - let code = crate::commands::run_update(false); + let code = crate::commands::run_update(); bootstrap::shutdown(); return code; } @@ -305,7 +336,7 @@ pub fn run(args: &[String]) -> i32 { // This shortcut is exactly one bare arg (args.len() == 2 above), // so it can never carry a `--home` override; an empty string // preserves the shortcut's existing default-home behavior. - let code = crate::commands::run_uninstall(false, ""); + let code = crate::commands::run_uninstall(false, false, ""); bootstrap::shutdown(); return code; } @@ -356,12 +387,14 @@ pub fn run(args: &[String]) -> i32 { { let text = app.render_help(&[args[2].clone()], color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(args, no_color_requested); bootstrap::shutdown(); return 0; } // No such command (or none given): fall back to the top-level help. let text = app.render_help(&[], color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(args, no_color_requested); bootstrap::shutdown(); return 0; } @@ -382,14 +415,19 @@ pub fn run(args: &[String]) -> i32 { // reproduces the doubled newline exactly. let text = app.render_help(&[], color_enabled(no_color_requested)); output::status_line(&text); + update_notice(&forwarded, no_color_requested); 0 } else { + if shows_update_notice(&path) { + update_notice(&forwarded, no_color_requested); + } code } } Outcome::Help { path } => { let text = app.render_help(&path, color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(&forwarded, no_color_requested); 0 } Outcome::Version { text } => { @@ -429,3 +467,40 @@ fn print_parse_error_help(app: &App, message: &str, path: &[String], no_color_re let text = app.render_help(path, color_enabled(no_color_requested)); eprint!("{}", text); } + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn update_notice_shows_on_help_page_commands_only() { + for name in [ + "run", + "serve", + "models", + "account", + "about", + "opencode", + "claude-code", + ] { + assert!(shows_update_notice(&argv(&[name])), "{name}"); + } + for name in ["update", "uninstall", "bench", "llm"] { + assert!(!shows_update_notice(&argv(&[name])), "{name}"); + } + assert!(!shows_update_notice(&[])); + } + + #[test] + fn root_flags_stop_at_the_passthrough_separator() { + let line = argv(&["wally", "--json", "opencode", "--", "--quiet"]); + assert_eq!( + root_flags_before_passthrough(&line), + vec!["--json", "opencode"] + ); + } +} diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 7531df43..2ee1808c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -43,6 +43,12 @@ pub struct GlobalOptions { pub environment: String, pub base_url: String, pub api_key: String, + /// Skip the SDK's network phase entirely (telemetry/auth/device + /// registration). Set by commands that promise to never touch the + /// network — `wally decisions --local` — so the promise holds even in + /// development, where Phase 2 posts anonymous telemetry to the baked + /// staging backend. + pub offline: bool, } /// Validated control-plane connection resolved from GlobalOptions. @@ -990,7 +996,13 @@ pub fn bootstrap(options: &GlobalOptions) -> ResultC category mapper, + // tracked separately on the SDK side), and the list filter then hides + // the UNKNOWN row. The file-list path passes the category straight + // through, the same way the MLX row registers. + url: None, + files: CLEF_FLASH_GGUF_FILES, + download_size_bytes: 6486448192, + context_length: 0, + supports_thinking: false, + memory_required_bytes: 0, + cua_profile: "", + merge_key: Some("clef-flash-9b"), + harness_compatible: false, + }, + CatalogEntry { + // The `mlx-` prefix is the same backend-variant convention every + // other model uses (`mlx-qwen3-0.6b-4bit`). `clef-flash-mlx` is the + // short spelling: the id without the prefix would name the llama.cpp + // build, so the alias carries the suffix the rest of the CLI docs use. + id: "mlx-clef-flash-9b-4bit", + alias: Some("clef-flash-mlx"), + name: "Clef Flash 9B (decision)", + category: v1::ModelCategory::Decision, + framework: v1::InferenceFramework::Mlx, + format: v1::ModelFormat::Safetensors, + url: None, + files: CLEF_FLASH_MLX_4BIT_FILES, + download_size_bytes: 6213978243, + context_length: 0, + supports_thinking: false, + memory_required_bytes: 0, + cua_profile: "", + merge_key: Some("clef-flash-9b"), + harness_compatible: false, + }, ]; // AUTO-TRANSLITERATED DATA END @@ -4408,8 +4551,16 @@ const CATALOG: &[CatalogEntry] = &[ // modality's entries still live in CATALOG above, but are filtered out here, // so `models list`, lookups, suggestions and SDK registration all see LLMs // only. Delete is_llm and its four uses below to restore the full catalog. +// +// LOCAL(decision): decision rows are surfaced alongside LLMs. `wally +// decisions` loads them by catalog id, so they must resolve, register and +// list; every modality the cut hides (STT/TTS/VLM/...) stays hidden. This is +// additive: Language or Decision. fn is_llm(entry: &CatalogEntry) -> bool { - entry.category == v1::ModelCategory::Language + matches!( + entry.category, + v1::ModelCategory::Language | v1::ModelCategory::Decision + ) } // MLX is an Apple-only backend. On any other platform its entries are hidden diff --git a/src/catalog/model_ref.rs b/src/catalog/model_ref.rs index 1352f1a0..8ab807fa 100644 --- a/src/catalog/model_ref.rs +++ b/src/catalog/model_ref.rs @@ -61,8 +61,10 @@ fn looks_like_hf_ref(reference: &str) -> bool { /// A ref that names an existing directory or file on disk. Checked BEFORE the /// HF/URL branch but AFTER the catalog and the registry, so a local path can -/// never shadow a real model id. -fn is_local_path(reference: &str) -> bool { +/// never shadow a real model id. Public so a caller that must tell a path +/// apart from a registered id (the decisions transport picker) applies the +/// same predicate as `resolve` instead of a second, drifting copy. +pub fn is_local_path(reference: &str) -> bool { if reference.is_empty() || is_http_url(reference) || looks_like_hf_ref(reference) { return false; } diff --git a/src/commands/cmd_about.rs b/src/commands/cmd_about.rs index 75935757..86f9622d 100644 --- a/src/commands/cmd_about.rs +++ b/src/commands/cmd_about.rs @@ -90,17 +90,8 @@ pub fn register_about(app: &mut App) { let engines = collect_llm_backend_rows(); - // Which bottle this binary is: WALLY_BAKED_CONSOLE_API_URL is compiled - // in empty for a production build and non-empty for a dev one (see - // baked_endpoints.h.in) -- the same compile-time fact - // default_console_url() itself keys on. console_url is the *effective* - // target though, since an env override still wins over the bake. - let dev_channel = !env!("WALLY_BAKED_CONSOLE_API_URL").is_empty(); - let channel = if dev_channel { - "development" - } else { - "production" - }; + // console_url is the *effective* target: an env override still wins + // over the configure-time endpoint. let console_url = credentials::default_console_url(); let loaded = credentials::load().ok(); @@ -114,7 +105,6 @@ pub fn register_about(app: &mut App) { .field_str("idl_version", env!("WALLY_IDL_VERSION")) .field_str("idl_schema_sha256", env!("WALLY_IDL_SCHEMA_SHA256")) .field_str("platform", PLATFORM) - .field_str("channel", channel) .field_str("console", &console_url) .field_str("os", &device.os_version) .field_str("cpu", &device.chip) @@ -171,11 +161,10 @@ pub fn register_about(app: &mut App) { ), ); row("platform", PLATFORM); - row("channel", channel); // The console URL is deliberately NOT printed here. It is an internal - // endpoint (today `/api-dev`), it means nothing to the person reading - // `wally about`, and it was shown twice. It stays in `--json` so - // support and tooling can still read it. + // endpoint, it means nothing to the person reading `wally about`, and it + // was shown twice. It stays in `--json` so support and tooling can still + // read it. heading(&pal, "System"); row( diff --git a/src/commands/cmd_account.rs b/src/commands/cmd_account.rs index 57f41e10..ec4be25a 100644 --- a/src/commands/cmd_account.rs +++ b/src/commands/cmd_account.rs @@ -21,11 +21,11 @@ use crate::util::getenv; /// afterwards, only from the environment, and only with an origin that passes /// the same rules -- the path and request code stay exactly as sent. /// -/// The baked origin is paired with the baked API and is used only when that API -/// is the one being contacted. It used to apply to any API, so a dev build -/// pointed at another console rewrote that console's approval URL to the baked -/// origin and then refused it as off-origin -- `WALLY_CONSOLE_URL` alone could -/// not sign in to a local console (#91). +/// The configured origin is paired with the configured API and is used only +/// when that API is the one being contacted. It used to apply to any API, so a +/// build pointed at another console rewrote that console's approval URL to the +/// configured origin and then refused it as off-origin -- `WALLY_CONSOLE_URL` +/// alone could not sign in to a local console (#91). fn console_web_origin(console_url: &str) -> String { let configured = getenv("WALLY_CONSOLE_WEB_URL") // rcli-era override, still honored so it doesn't go silently unread @@ -34,14 +34,16 @@ fn console_web_origin(console_url: &str) -> String { let configured = match configured { Some(value) => value, None => { - // A dev build carries its approval console compiled in (see - // baked_endpoints.h.in) -- empty in production builds, and the env + // The configure-time origin (see credentials.rs), and the env // overrides above always win. Pairwise, exactly as // account::trusted_browser_origins pairs them. - let baked_api = account::baked_console_api_url(); - let baked_web = env!("WALLY_BAKED_CONSOLE_WEB_ORIGIN"); - if !baked_api.is_empty() && console_url == baked_api && !baked_web.is_empty() { - baked_web.to_string() + let configured_api = account::configured_console_api_url(); + let configured_web = env!("WALLY_CONSOLE_WEB_ORIGIN"); + if !configured_api.is_empty() + && console_url == configured_api + && !configured_web.is_empty() + { + configured_web.to_string() } else { return String::new(); } @@ -496,8 +498,8 @@ pub fn register_account(app: &mut App) { Example::new("wally account login", ""), Example::new("wally account login --no-browser", ""), ])); - // The console origin is not a user-facing flag: it comes from the baked - // default, or WALLY_CONSOLE_URL for a dev build (read directly in + // The console origin is not a user-facing flag: it comes from the build's + // configured endpoint, or WALLY_CONSOLE_URL (read directly in // credentials.rs). login() falls back to that when handed an empty string. login_cmd.callback(|p, _g| login("", !p.flag("--no-browser"))); @@ -611,11 +613,11 @@ mod tests { let saved = std::env::var_os("WALLY_CONSOLE_WEB_URL"); // SAFETY: `_lock` serializes every test in this process that touches // WALLY_CONSOLE_WEB_URL. - unsafe { std::env::set_var("WALLY_CONSOLE_WEB_URL", "https://console.runanywhere.ai") }; + unsafe { std::env::set_var("WALLY_CONSOLE_WEB_URL", "https://console.example.test") }; let result = rebase_approval_url( - "https://runanywhere-frontend-production.up.railway.app?code=abc", - "https://inference.runanywhere.ai", + "https://console-alt.example.test?code=abc", + "https://api.example.test", ); match saved { @@ -624,7 +626,7 @@ mod tests { None => unsafe { std::env::remove_var("WALLY_CONSOLE_WEB_URL") }, } - assert_eq!(result, "https://console.runanywhere.ai?code=abc"); + assert_eq!(result, "https://console.example.test?code=abc"); } // #137: refresh_session must not string-match the console's message to @@ -657,7 +659,7 @@ mod tests { }, ) as Transport)); let mut credentials = Credentials { - console_url: "https://inference.runanywhere.ai".to_string(), + console_url: "https://api.example.test".to_string(), refresh_token: "refresh-token".to_string(), ..Credentials::default() }; @@ -679,8 +681,11 @@ mod tests { ), "the console's own message must still lead: {failure}" ); + // The console here is not the one this test binary was configured + // against (a test build has none, or another deployment's), so its + // billing page is its own sibling origin. assert!( - failure.contains("https://console.runanywhere.ai/cloud/billing"), + failure.contains("https://api.example.test/cloud/billing"), "the refresh failure must name the billing URL: {failure}" ); } diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 2b8d6671..ee95750e 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -1,15 +1,30 @@ -//! Hosted decision scoring (`wally decisions`, alias `decide`): a decision -//! model answers typed questions with label probabilities instead of text. +//! Decision scoring (`wally decisions`, alias `decide`): a decision model +//! answers typed questions with label probabilities instead of text. +//! +//! Two transports, one request/response contract. `--cloud` (or no local +//! artifact for `--model`) scores on the hosted console; `--local` (or a +//! `--model` that resolves to an on-disk artifact) scores in-process through +//! the kit's `rac_decision_*` component. The request shape, the rendering and +//! `--json` output are shared, so a script can point the same invocation at +//! either transport. +use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::ffi::CString; use std::io::Read; +use std::io::Write as _; use std::time::Instant; use crate::account::decisions_contract as contract; use crate::account::{self, ConsoleClient, ConsoleSession}; +use crate::bootstrap::{self, GlobalOptions}; use crate::cli::{App, ValueType}; use crate::cli_formatter::{examples_footer, Example}; +use crate::commands::model_setup::ensure_model_ready; +use crate::config::preferences::{self, DecisionsRoute}; use crate::io::output as out; +use crate::io::proto::{parse_proto_buffer, serialize, v1, ProtoBuffer}; +use crate::sys; use crate::util::term; const DEFAULT_MODEL: &str = crate::harness::DEFAULT_DECISIONS_MODEL; @@ -46,18 +61,26 @@ fn nonblank(value: &str, label: &str, maximum: usize) -> Result<(), String> { Ok(()) } -fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { - nonblank(&request.input, "input", 1_000_000)?; - nonblank(&request.model, "model", 128)?; - let mut chars = request.model.chars(); +/// The hosted console accepts a model id, not a path; the local transport +/// accepts a catalog id, a registered id, or a path on this machine. So the +/// console's id-shape rule is applied by the cloud branch, not here — a +/// local path would otherwise be refused before the transport is chosen. +fn validate_model_shape(model: &str) -> Result<(), String> { + nonblank(model, "model", 128)?; + let mut chars = model.chars(); if !chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) - || !request - .model + || !model .chars() .all(|c| c.is_ascii_alphanumeric() || "._:/-".contains(c)) { return Err("model must be a valid Wally model id".to_string()); } + Ok(()) +} + +pub(crate) fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { + nonblank(&request.input, "input", 1_000_000)?; + nonblank(&request.model, "model", 128)?; if !(1..=128).contains(&request.questions.len()) { return Err("provide 1 to 128 questions".to_string()); } @@ -146,6 +169,7 @@ fn build_request(p: &crate::cli::Parsed) -> Result Vec { +pub(crate) fn unknown_request_fields(value: &serde_json::Value) -> Vec { let Ok(root) = serde_json::from_str::(CONTRACT) else { return Vec::new(); }; @@ -438,7 +462,15 @@ fn top_row(rows: &[(String, f64)]) -> Option<&(String, f64)> { }) } -fn render_human(request: &contract::DecisionsRequest, response: &contract::DecisionsResponse) { +/// `label_mass_present` is the transport capability: the cloud server reports +/// the share of the whole next-token distribution that landed on the +/// question's labels, the local head does not. The low-fit note is only +/// printed when a mass actually exists to judge. +fn render_human( + request: &contract::DecisionsRequest, + response: &contract::DecisionsResponse, + label_mass_present: bool, +) { for (id, view) in question_views(request) { let Some(answer) = response.answers.0.get(&id) else { continue; @@ -464,7 +496,7 @@ fn render_human(request: &contract::DecisionsRequest, response: &contract::Decis probability * 100.0 )); } - if answer.label_mass < 0.5 { + if label_mass_present && answer.label_mass < 0.5 { out::result_line(" note: low fit — most probability was outside these labels"); } out::result_line(""); @@ -477,14 +509,548 @@ fn display_label_line(text: &str) -> String { text.chars().filter(|c| !c.is_control()).collect() } -fn run(p: &crate::cli::Parsed, json: bool) -> i32 { - let request = match build_request(p) { +/// The local decision model a bare `wally decisions` runs: the GGUF row where +/// the linked kit has llama.cpp, the MLX row on Apple builds without it. +/// `None` where this build cannot score anything on-device. +fn default_local_model() -> Option<&'static str> { + if cfg!(wally_has_llamacpp) { + Some("clef-flash-9b") + } else if cfg!(target_os = "macos") { + Some("clef-flash-mlx") + } else { + None + } +} + +/// Parse a route-menu answer: `1` is cloud, `2` is local, anything else +/// (including an empty line) takes the suggestion. +fn parse_route_choice(line: &str, suggested: DecisionsRoute) -> DecisionsRoute { + match line.trim_end_matches(['\n', '\r']).trim() { + "1" => DecisionsRoute::Cloud, + "2" => DecisionsRoute::Local, + _ => suggested, + } +} + +/// Ask once where bare invocations score. Numbered with local suggested +/// (the prompt only runs where a local model exists), Enter takes it. +/// Everything on stderr so `--json` stays clean. +fn prompt_decisions_route(local_id: &str) -> DecisionsRoute { + let signed_in = account::load().map(|c| c.signed_in()).unwrap_or(false); + let login_hint = if signed_in { + String::new() + } else { + " (needs `wally account login` first)".to_string() + }; + eprint!( + "How should decisions be scored? One-time question, your answer is saved as the default.\n 1) cloud: hosted {} model{}\n 2) local: on-device {}, data stays on this machine\nChoice [1/2, Enter=2]: ", + DEFAULT_MODEL, login_hint, local_id + ); + let _ = std::io::stderr().flush(); + let mut answer = String::new(); + if std::io::stdin().read_line(&mut answer).unwrap_or(0) == 0 { + return DecisionsRoute::Local; + } + parse_route_choice(&answer, DecisionsRoute::Local) +} + +/// Which transport `wally decisions` should use. `Auto` is resolved by +/// whether `--model` names a local artifact: a decision model on this machine +/// is what the command runs; otherwise the hosted console. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Transport { + Auto, + Local, + Cloud, +} + +fn transport_from_flags(p: &crate::cli::Parsed) -> Result { + let local = p.flag("--local"); + let cloud = p.flag("--cloud"); + match (local, cloud) { + (true, true) => Err("--local and --cloud are mutually exclusive".to_string()), + (true, false) => Ok(Transport::Local), + (false, true) => Ok(Transport::Cloud), + (false, false) => Ok(Transport::Auto), + } +} + +/// Whether `reference` names something the local decision component can load. +/// +/// The catalog and the filesystem answer most references with no SDK +/// involved. A non-catalog, non-path id needs the registry, and a fresh +/// process has an empty in-memory registry until bootstrap restores the +/// manifest entries — so the probe bootstraps in offline mode (no +/// telemetry/auth, no network) before reading the stored category. A +/// registered id is local only when that category is Decision; an HF-pulled +/// LLM registered earlier must keep going to the hosted transport, and a +/// hosted decision id (`eve`) is never registered locally at all. +pub(crate) fn resolve_local_transport( + reference: &str, + options: &GlobalOptions, +) -> Result { + if let Some(entry) = crate::catalog::find(reference) { + return Ok(entry.category == v1::ModelCategory::Decision); + } + // A path on disk is a local artifact regardless of the registry. + if crate::catalog::model_ref::is_local_path(reference) { + return Ok(true); + } + // Known hosted decision ids are cloud by definition and are never + // registered locally; skip the SDK init the registry read needs so the + // default `eve` invocation keeps its fast path. + if crate::harness::is_decisions_model(reference) { + return Ok(false); + } + let offline = GlobalOptions { + offline: true, + ..options.clone() + }; + bootstrap::bootstrap(&offline) + .map_err(|rc| format!("cannot inspect local models: {}", out::describe_result(rc)))?; + // Manifest-restored pulls come back here. A failed refresh leaves + // whatever is already registered, so the category read is best-effort. + let _ = crate::commands::model_setup::refresh_registry(); + Ok(registered_category(reference) == Some(v1::ModelCategory::Decision)) +} + +/// The stored category of a registered model id, if the registry knows it. +fn registered_category(reference: &str) -> Option { + let id = CString::new(reference).ok()?; + let mut out = ProtoBuffer::new(); + // SAFETY: `id` is a valid NUL-terminated string for the call; `out` is a + // freshly-initialised buffer. + let rc = unsafe { + sys::rac_model_registry_get_proto_buffer( + sys::rac_get_model_registry(), + id.as_ptr(), + out.as_mut_ptr(), + ) + }; + if rc != sys::SUCCESS { + return None; + } + parse_proto_buffer::(out) + .ok() + .and_then(|info| v1::ModelCategory::try_from(info.category).ok()) +} + +/// The response shape `create_decisions` returns, rebuilt locally so the +/// shared renderer/`--json` path is identical across transports. `label_mass` +/// is left at its default 0 and the renderer is told it is absent: the local +/// head has no whole-distribution mass to report, and faking one would be a +/// lie the `--json` output carried. +fn local_response( + result: &v1::DecisionResult, + request: &contract::DecisionsRequest, +) -> contract::DecisionsResponse { + let mut answers = BTreeMap::new(); + for answer in &result.answers { + let r#type = match v1::DecisionQuestionType::try_from(answer.r#type) { + Ok(v1::DecisionQuestionType::Choice) => "choice", + Ok(v1::DecisionQuestionType::Noul) => "yes_no", + Ok(v1::DecisionQuestionType::Score) => "score", + _ => "", + } + .to_string(); + // The local head keys probabilities by the option key it was given. + // For a yes/no question the contract renders "yes"/"no", so the + // canonical true/false keys are re-keyed the same way the SDK does. + let mut probabilities = BTreeMap::new(); + for (key, probability) in &answer.probabilities { + let key = match (r#type.as_str(), key.as_str()) { + ("yes_no", "true") => "yes".to_string(), + ("yes_no", "false") => "no".to_string(), + (_, other) => other.to_string(), + }; + probabilities.insert(key, f64::from(*probability)); + } + let choice = match (&answer.answer, r#type.as_str()) { + (Some(v1::decision_answer::Answer::Choice(key)), "choice") => Some(key.clone()), + _ => None, + }; + let score = match &answer.answer { + Some(v1::decision_answer::Answer::Score(value)) => Some(f64::from(*value)), + _ => None, + }; + answers.insert( + answer.id.clone(), + contract::DecisionAnswer { + choice, + label_mass: 0.0, + probabilities: contract::LabelProbabilities(probabilities), + score, + r#type, + }, + ); + } + let input_tokens = result + .usage + .as_ref() + .map(|usage| i64::from(usage.input_tokens)) + .unwrap_or(0); + contract::DecisionsResponse { + answers: contract::DecisionAnswers(answers), + model: if result.model_id.is_empty() { + request.model.clone() + } else { + result.model_id.clone() + }, + object: "decision".to_string(), + prompt_format_version: i64::from(result.prompt_format_version), + usage: contract::DecisionUsage { + completion_tokens: 0, + prompt_tokens: input_tokens, + reasoning_tokens: None, + total_tokens: input_tokens, + }, + } +} + +/// Build the proto request the local component takes from the contract-shaped +/// request the CLI built. The cloud `yes_no` question is the local NOUL; choice +/// options map name→key (the name IS the canonical key the renderer expects); +/// score levels map to ordered keys "0".."n-1" with the level text as the +/// description, so the answer's `legend` can label the expected level. +fn local_request(request: &contract::DecisionsRequest) -> v1::DecisionRequest { + let questions = request + .questions + .iter() + .map(|question| match question { + contract::DecisionQuestion::YesNoQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Noul as i32, + instructions: value.question.clone(), + options: vec![ + v1::DecisionOption { + key: "true".to_string(), + description: value.yes.clone().unwrap_or_default(), + }, + v1::DecisionOption { + key: "false".to_string(), + description: value.no.clone().unwrap_or_default(), + }, + ], + }, + contract::DecisionQuestion::ChoiceQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Choice as i32, + instructions: value.question.clone(), + options: value + .options + .iter() + .map(|option| v1::DecisionOption { + key: option.name.clone(), + description: option.description.clone().unwrap_or_default(), + }) + .collect(), + }, + contract::DecisionQuestion::ScoreQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Score as i32, + instructions: value.question.clone(), + options: value + .levels + .iter() + .enumerate() + .map(|(index, level)| v1::DecisionOption { + key: index.to_string(), + description: level.clone(), + }) + .collect(), + }, + }) + .collect(); + + let options = + (request.temperature.is_some() || request.prompt_format_version.is_some()).then(|| { + v1::DecisionOptions { + temperature: request.temperature.unwrap_or(0.0) as f32, + prompt_format_version: request.prompt_format_version.unwrap_or(0) as u32, + } + }); + + v1::DecisionRequest { + state: request.input.clone(), + questions, + options, + model_id: (!request.model.is_empty()).then(|| request.model.clone()), + } +} + +/// Score through the local component: resolve the model, load it through the +/// lifecycle (auto-pull when missing), drive the decision component, and hand +/// the mapped response back to the shared renderer. +/// A created, model-loaded decision component. Dropping unloads nothing +/// extra (unload is per-model on reload); destroy always runs exactly once. +pub(crate) struct LoadedDecisions { + handle: sys::rac_handle_t, +} + +impl Drop for LoadedDecisions { + fn drop(&mut self) { + // SAFETY: handle came from rac_decision_component_create and is + // destroyed exactly once here; nothing else touches it afterwards. + unsafe { sys::rac_decision_component_destroy(self.handle) }; + } +} + +/// Create the component and load `model` into it. Error strings match what +/// run_local printed inline, so both callers report identically. +pub(crate) fn load_decisions_component( + model: &crate::commands::model_setup::ResolvedModelPaths, +) -> Result { + let mut handle: sys::rac_handle_t = std::ptr::null_mut(); + // SAFETY: `handle` is a valid out-pointer for the duration of the call. + let rc = unsafe { sys::rac_decision_component_create(&mut handle) }; + if rc != sys::SUCCESS || handle.is_null() { + return Err("failed to create decision component".to_string()); + } + let loaded = LoadedDecisions { handle }; + + let model_path = CString::new(model.primary_path.as_str()); + let model_id = CString::new(model.model_id.as_str()); + let model_name = CString::new(model.display_name.as_str()); + let (model_path, model_id, model_name) = match (model_path, model_id, model_name) { + (Ok(path), Ok(id), Ok(name)) => (path, id, name), + _ => return Err("model path, id or name contains a NUL byte".to_string()), + }; + // SAFETY: handle is live; the three C strings outlive this call. + let rc = unsafe { + sys::rac_decision_component_load_model( + loaded.handle, + model_path.as_ptr(), + model_id.as_ptr(), + model_name.as_ptr(), + ) + }; + if rc != sys::SUCCESS { + return Err(format!( + "failed to load decision model: {}", + out::describe_result(rc) + )); + } + Ok(loaded) +} + +/// Score one request on an already-loaded component. Error strings match +/// what run_local printed inline (partial results fail naming the rc, a +/// missing answer id fails naming the id). +pub(crate) fn score_loaded( + loaded: &LoadedDecisions, + request: &contract::DecisionsRequest, +) -> Result { + let proto_request = local_request(request); + let bytes = serialize(&proto_request); + let mut out_buffer = ProtoBuffer::new(); + // SAFETY: handle is live and loaded; bytes/out_buffer are valid for the + // duration of the call. + let proto_rc = unsafe { + sys::rac_decision_component_decide_proto( + loaded.handle, + bytes.as_ptr(), + bytes.len(), + out_buffer.as_mut_ptr(), + ) + }; + let result = match parse_proto_buffer::(out_buffer) { + Ok(result) if proto_rc == sys::SUCCESS => result, + Ok(result) if !result.answers.is_empty() => { + return Err(format!( + "decision failed: {} ({} of {} questions answered)", + out::describe_result(proto_rc), + result.answers.len(), + request.questions.len() + )); + } + Ok(_) => return Err("decision failed: ".to_string()), + Err(error) => return Err(format!("decision failed: {error}")), + }; + + // Every requested question must carry an answer, whatever the rc said; + // a missing id is the same partial failure the arm above rejects. + if let Some((missing, _)) = question_views(request) + .into_iter() + .find(|(id, _)| !result.answers.iter().any(|answer| answer.id == *id)) + { + return Err(format!( + "decision failed: no answer for question '{missing}'" + )); + } + + Ok(local_response(&result, request)) +} + +fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json: bool) -> i32 { + if bootstrap::bootstrap(options).is_err() { + return 1; + } + if let Err(error) = crate::commands::model_setup::refresh_registry() { + out::status_line(&format!("warning: registry refresh failed: {error}")); + } + + let model = match ensure_model_ready(options, &request.model) { + Ok(model) => model, + Err(exit_code) => return exit_code, + }; + + let loaded = match load_decisions_component(&model) { + Ok(loaded) => loaded, + Err(message) => { + out::error_line(&message); + return 1; + } + }; + + let started = Instant::now(); + let response = match score_loaded(&loaded, request) { + Ok(response) => response, + Err(message) => { + out::error_line(&message); + return 1; + } + }; + let latency_ms = started.elapsed().as_millis(); + if json { + out::result_line(&crate::io::json::dump(&response.to_json())); + } else { + render_human(request, &response, false); + } + out::status_line(&format!( + "{} · {} tokens · local · {}ms", + response.model, response.usage.total_tokens, latency_ms + )); + 0 +} + +fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { + let mut transport = match transport_from_flags(p) { + Ok(transport) => transport, + Err(error) => { + out::error_line(&error); + return 2; + } + }; + // `--route` saves the default; validated here, persisted after the + // request checks out so a failing invocation never saves a preference. + // It already selects the route, so the per-run flags and a request file + // (which carries its own model) have nothing left to say. + let mut forced_route = None; + if let Some(route) = p.get_str("--route") { + if p.flag("--local") || p.flag("--cloud") || p.is_set("--request") { + out::error_line("--route cannot be combined with --local, --cloud, or --request"); + return 2; + } + match preferences::parse_decisions_route(Some(&route)) { + Some(parsed) => forced_route = Some(parsed), + None => { + out::error_line(&format!( + "decisions route must be 'local' or 'cloud', not '{route}'" + )); + return 2; + } + } + } + let mut request = match build_request(p) { Ok(request) => request, Err(error) => { out::error_line(&error); return 2; } }; + if let Some(route) = forced_route { + if let Err(error) = preferences::set_decisions_route(route.name()) { + out::error_line(&error); + return 2; + } + out::status_line(&format!("saved decisions route: {}", route.name())); + } + // No flags and no model is the first-run shape: follow the saved route, + // ask once when there is none, and stay on today's cloud default where + // no answer can come (a pipe) or nothing local can run. + if transport == Transport::Auto && !p.is_set("--model") && !p.is_set("--request") { + let route = match forced_route.or_else(preferences::saved_decisions_route) { + Some(saved) => { + out::status_line(&format!("using saved decisions route: {}", saved.name())); + saved + } + None if !term::stdin_is_tty() => DecisionsRoute::Cloud, + None => match default_local_model() { + None => DecisionsRoute::Cloud, + Some(local_id) => { + let choice = prompt_decisions_route(local_id); + match preferences::set_decisions_route(match choice { + DecisionsRoute::Local => "local", + DecisionsRoute::Cloud => "cloud", + }) { + Ok(_) => out::status_line("saved as your decisions default"), + Err(error) => out::status_line(&format!( + "warning: choice not saved ({error}); continuing anyway" + )), + } + choice + } + }, + }; + if route == DecisionsRoute::Local { + match default_local_model() { + Some(local_id) => { + request.model = local_id.to_string(); + transport = Transport::Local; + } + None => { + out::error_line( + "saved decisions route is 'local' but this build cannot score on-device", + ); + return 2; + } + } + } + } + let local = match transport { + Transport::Local => match resolve_local_transport(&request.model, options) { + Ok(true) => true, + Ok(false) => { + out::error_line(&format!( + "local scoring was selected but '{}' does not resolve to a model on this machine", + request.model + )); + return 2; + } + Err(error) => { + out::error_line(&error); + return 1; + } + }, + Transport::Cloud => false, + Transport::Auto => match resolve_local_transport(&request.model, options) { + Ok(local) => local, + Err(error) => { + out::error_line(&error); + return 1; + } + }, + }; + if local { + if p.is_set("--no-retry") { + out::error_line("--no-retry is a cloud-only flag"); + return 2; + } + // The local path promises no network; bootstrap must not run the + // SDK's telemetry/auth phase, which posts anonymously to the staging + // backend in development. + let offline = GlobalOptions { + offline: true, + ..options.clone() + }; + return run_local(&offline, &request, json); + } + // The hosted console takes a model id, not a path; only the local + // transport accepts a path, so the id-shape rule gates cloud here. + if let Err(error) = validate_model_shape(&request.model) { + out::error_line(&error); + return 2; + } let credentials = match account::load() { Ok(credentials) if credentials.signed_in() => credentials, Ok(_) => { @@ -539,7 +1105,7 @@ fn run(p: &crate::cli::Parsed, json: bool) -> i32 { if json { out::result_line(&result.raw_json); } else { - render_human(&request, &result.response); + render_human(&request, &result.response, true); } let usage = &result.response.usage; let cost = session @@ -606,6 +1172,19 @@ pub fn register_decisions(app: &mut App) { "Probability temperature (>0, at most 100)", ); cmd.add_flag("--json", "Print the raw response JSON"); + cmd.add_flag( + "--local", + "Score on this machine with a local decision model (never the network)", + ); + cmd.add_flag( + "--cloud", + "Score on your account's hosted endpoint (never a local model)", + ); + cmd.add_option( + "--route", + ValueType::Text, + "Save 'local' or 'cloud' as the default route (used when no flags are given)", + ); cmd.add_flag("--no-retry", "Do not retry HTTP 429 or 503"); cmd.footer(&examples_footer(&[ Example::new( @@ -616,8 +1195,12 @@ pub fn register_decisions(app: &mut App) { "wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account'", "", ), + Example::new( + "wally decisions --local -m clef-flash-9b --input 'Checkout is blank' --ask 'Is this a bug?'", + "", + ), ])); - cmd.callback(|p, global| run(p, p.flag("--json") || global.json)); + cmd.callback(|p, global| run(p, global, p.flag("--json") || global.json)); } #[cfg(test)] @@ -652,6 +1235,44 @@ mod tests { assert!(crate::harness::is_decisions_model(DEFAULT_MODEL)); } + #[test] + fn default_local_model_matches_what_this_build_can_run() { + // Mirrors the catalog gating the transport test asserts on: GGUF + // where llama.cpp is linked, MLX on Apple builds without it, and no + // local model where neither exists. + if cfg!(wally_has_llamacpp) { + assert_eq!(default_local_model(), Some("clef-flash-9b")); + } else if cfg!(target_os = "macos") { + assert_eq!(default_local_model(), Some("clef-flash-mlx")); + } else { + assert_eq!(default_local_model(), None); + } + } + + #[test] + fn route_menu_numbers_are_stable_and_blank_takes_the_suggestion() { + assert_eq!( + parse_route_choice("1", DecisionsRoute::Local), + DecisionsRoute::Cloud + ); + assert_eq!( + parse_route_choice("2", DecisionsRoute::Cloud), + DecisionsRoute::Local + ); + assert_eq!( + parse_route_choice("", DecisionsRoute::Local), + DecisionsRoute::Local + ); + assert_eq!( + parse_route_choice(" \n", DecisionsRoute::Cloud), + DecisionsRoute::Cloud + ); + assert_eq!( + parse_route_choice("3", DecisionsRoute::Local), + DecisionsRoute::Local + ); + } + #[test] fn request_file_with_an_unknown_field_is_named_at_every_depth() { let value = serde_json::json!({ @@ -754,4 +1375,218 @@ mod tests { assert_eq!(display_label(&long).chars().count(), LABEL_MAX_CHARS); assert!(display_label(&long).ends_with('…')); } + + fn ticket_request() -> contract::DecisionsRequest { + contract::DecisionsRequest { + model: "clef-flash-9b".to_string(), + input: "ticket".to_string(), + questions: vec![ + contract::DecisionQuestion::YesNoQuestion(contract::YesNoQuestion { + id: "refund".to_string(), + question: "Refund?".to_string(), + r#type: "yes_no".to_string(), + yes: Some("asks for a refund".to_string()), + no: Some("no refund".to_string()), + }), + contract::DecisionQuestion::ScoreQuestion(contract::ScoreQuestion { + id: "urgency".to_string(), + question: "Urgency?".to_string(), + r#type: "score".to_string(), + levels: vec!["low".into(), "medium".into(), "high".into()], + }), + ], + ..Default::default() + } + } + + #[test] + fn local_request_maps_cloud_shapes_onto_the_proto_contract() { + let proto = local_request(&ticket_request()); + assert_eq!(proto.state, "ticket"); + assert_eq!(proto.questions.len(), 2); + let refund = &proto.questions[0]; + assert_eq!(refund.r#type, v1::DecisionQuestionType::Noul as i32); + // The engine finds the affirmative option by the canonical key. + assert_eq!(refund.options[0].key, "true"); + assert_eq!(refund.options[1].key, "false"); + let urgency = &proto.questions[1]; + assert_eq!(urgency.r#type, v1::DecisionQuestionType::Score as i32); + // Score levels become ordered keys with the level text as legend. + let keys: Vec<&str> = urgency.options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["0", "1", "2"]); + assert_eq!(urgency.options[2].description, "high"); + } + + #[test] + fn local_response_maps_the_proto_result_back_to_the_cloud_shape() { + let request = ticket_request(); + let mut result = v1::DecisionResult { + model_id: "clef-flash-9b".to_string(), + prompt_format_version: 3, + ..Default::default() + }; + let mut refund = v1::DecisionAnswer { + id: "refund".to_string(), + r#type: v1::DecisionQuestionType::Noul as i32, + confidence: 0.93, + ..Default::default() + }; + refund.probabilities.insert("true".to_string(), 0.93_f32); + refund.probabilities.insert("false".to_string(), 0.07_f32); + refund.answer = Some(v1::decision_answer::Answer::Noul(0.93)); + let mut urgency = v1::DecisionAnswer { + id: "urgency".to_string(), + r#type: v1::DecisionQuestionType::Score as i32, + ..Default::default() + }; + urgency.probabilities.insert("2".to_string(), 0.7_f32); + urgency.answer = Some(v1::decision_answer::Answer::Score(1.7)); + result.answers = vec![refund, urgency]; + result.usage = Some(v1::TokenUsage { + input_tokens: 97, + ..Default::default() + }); + + let response = local_response(&result, &request); + assert_eq!(response.model, "clef-flash-9b"); + assert_eq!(response.prompt_format_version, 3); + assert_eq!(response.usage.total_tokens, 97); + let refund_answer = &response.answers.0["refund"]; + // yes_no keys are re-keyed to the contract's yes/no spelling. + assert_eq!(refund_answer.r#type, "yes_no"); + assert!((refund_answer.probabilities.0["yes"] - 0.93).abs() < 1e-6); + assert!((refund_answer.probabilities.0["no"] - 0.07).abs() < 1e-6); + let urgency_answer = &response.answers.0["urgency"]; + assert_eq!(urgency_answer.r#type, "score"); + assert!((urgency_answer.score.unwrap() - 1.7).abs() < 1e-6); + // The local head reports no whole-distribution mass; the renderer is + // told it is absent rather than printing a fake low-fit note. + assert_eq!(refund_answer.label_mass, 0.0); + } + + #[test] + fn local_response_choice_keeps_the_option_key() { + let request = contract::DecisionsRequest { + model: "clef-flash-9b".to_string(), + input: "ticket".to_string(), + questions: vec![contract::DecisionQuestion::ChoiceQuestion( + contract::ChoiceQuestion { + id: "team".to_string(), + question: "Owner?".to_string(), + r#type: "choice".to_string(), + options: vec![ + contract::DecisionOption { + name: "billing".into(), + description: None, + }, + contract::DecisionOption { + name: "technical".into(), + description: None, + }, + ], + }, + )], + ..Default::default() + }; + let mut result = v1::DecisionResult::default(); + let mut answer = v1::DecisionAnswer { + id: "team".to_string(), + r#type: v1::DecisionQuestionType::Choice as i32, + ..Default::default() + }; + answer.probabilities.insert("billing".to_string(), 0.97_f32); + answer + .probabilities + .insert("technical".to_string(), 0.03_f32); + answer.answer = Some(v1::decision_answer::Answer::Choice("billing".to_string())); + result.answers = vec![answer]; + + let response = local_response(&result, &request); + let mapped = &response.answers.0["team"]; + assert_eq!(mapped.choice.as_deref(), Some("billing")); + assert!((mapped.probabilities.0["billing"] - 0.97).abs() < 1e-6); + assert!(mapped.score.is_none()); + } + + #[test] + fn catalog_decisions_are_local_and_hosted_ids_are_cloud() { + // The registry probe (the unknown-word case) bootstraps the SDK, so + // point it at a throwaway home rather than the real one. Every other + // case below answers before the registry is touched. + let dir = std::env::temp_dir().join(format!( + "wally-decisions-transport-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let options = GlobalOptions { + home_override: dir.to_string_lossy().into_owned(), + ..GlobalOptions::default() + }; + + // A catalog decision row is local on a build whose linked kit can run + // it; an LLM row in the same catalog is never local (it would fail at + // the decision component). The GGUF row is llama.cpp-gated, so the + // commons-only Windows ARM64 kit filters it out. + assert_eq!( + resolve_local_transport("clef-flash-9b", &options), + Ok(cfg!(wally_has_llamacpp)) + ); + // The MLX row is platform-gated (Apple only), so off-macOS the + // catalog filter hides it and it does not resolve locally. + assert_eq!( + resolve_local_transport("clef-flash-mlx", &options), + Ok(cfg!(target_os = "macos")) + ); + assert_eq!(resolve_local_transport("qwen3-0.6b", &options), Ok(false)); + // A hosted decision id is cloud by definition and must not be treated + // as a local artifact (nor pay for an SDK init to find that out). + assert_eq!(resolve_local_transport("eve", &options), Ok(false)); + // An unknown bare word is not local; the registry probe runs, finds + // nothing, and says so. + assert_eq!( + resolve_local_transport("definitely-not-a-model", &options), + Ok(false) + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn transport_flags_are_mutually_exclusive() { + use crate::cli::Parsed; + let flag = |name: &str| { + let mut parsed = Parsed::default(); + parsed + .name_to_spec + .insert(name.to_string(), name.to_string()); + parsed + .flag_values + .insert(name.to_string(), vec!["true".to_string()]); + parsed + }; + + let mut both = flag("--local"); + both.name_to_spec + .insert("--cloud".to_string(), "--cloud".to_string()); + both.flag_values + .insert("--cloud".to_string(), vec!["true".to_string()]); + assert!(transport_from_flags(&both).is_err()); + + assert_eq!( + transport_from_flags(&flag("--local")).unwrap(), + Transport::Local + ); + assert_eq!( + transport_from_flags(&flag("--cloud")).unwrap(), + Transport::Cloud + ); + assert_eq!( + transport_from_flags(&Parsed::default()).unwrap(), + Transport::Auto + ); + } } diff --git a/src/commands/cmd_editors.rs b/src/commands/cmd_editors.rs index ef29f39e..5a5d1756 100644 --- a/src/commands/cmd_editors.rs +++ b/src/commands/cmd_editors.rs @@ -597,7 +597,12 @@ fn run(editor: &Editor, model: &str, args: &[String], options: &GlobalOptions) - // `ANTHROPIC_MODEL` is read before the `model` settings key, and // `--model` or `/model` still override it, which is correct -- a reader // who asks for something else inside the session should get it. - let _selected_model = ScopedEnv::new("ANTHROPIC_MODEL", model); + // Carry the model as a `--model` flag instead of ANTHROPIC_MODEL, so + // the real id is never renamed and labels stay correct. If the reader + // already passed their own --model, theirs wins and nothing is + // prepended. ANTHROPIC_MODEL is deliberately unset here: when both + // are present the env value shadows the flag, which would defeat this. + let _no_selected_model = ScopedUnsetEnv::new("ANTHROPIC_MODEL"); // Claude Code's picker is Anthropic-family (Opus/Sonnet/Haiku), not a model // list, so each catalog model is bound to a family slot: they all then show // in the picker, labelled with their real ids. The launched model is first, @@ -612,7 +617,25 @@ fn run(editor: &Editor, model: &str, args: &[String], options: &GlobalOptions) - for (index, catalog_model) in catalog.iter().take(3).enumerate() { _family_slots.push(ScopedEnv::new(FAMILY_SLOTS[index], &catalog_model.id)); } - status = harness::launch(editor.command, "", args, options); + // The picker renders each bound slot as "Custom model" + // unless its description override is set, so label all three with + // the provider. The label keeps the real id; only this suffix changes. + const SLOT_DESCRIPTIONS: [&str; 3] = [ + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", + "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", + ]; + let mut _slot_descriptions: Vec = Vec::new(); + for name in SLOT_DESCRIPTIONS { + _slot_descriptions.push(ScopedEnv::new(name, "RunAnywhere model")); + } + let mut launch_args = Vec::with_capacity(args.len() + 2); + if !args.iter().any(|a| a == "--model") { + launch_args.push("--model".to_string()); + launch_args.push(model.to_string()); + } + launch_args.extend(args.iter().cloned()); + status = harness::launch(editor.command, "", &launch_args, options); } anthropic::stop(&mut shim); diff --git a/src/commands/cmd_list.rs b/src/commands/cmd_list.rs index bb5f7c48..382dff6f 100644 --- a/src/commands/cmd_list.rs +++ b/src/commands/cmd_list.rs @@ -1,5 +1,7 @@ //! `wally models list` (alias `wally models ls`) — downloaded models by -//! default, the whole catalog with --all (port of src/commands/cmd_list.cpp). +//! default; the whole local catalog with --local; the signed-in account's +//! cloud models with --cloud; both with --all (port of +//! src/commands/cmd_list.cpp). //! //! The registry is refreshed with rescan_local so on-disk artifacts pulled by //! previous runs (or by the test rig / playground tooling) are linked before @@ -7,6 +9,7 @@ use std::collections::HashMap; +use crate::account::{ConsoleClient, ConsoleSession}; use crate::bootstrap::{bootstrap, GlobalOptions}; use crate::cli::App; use crate::commands::model_labels; @@ -119,8 +122,11 @@ fn group_models( continue; } // LLM-only surface: a downloaded non-LLM model restored from a manifest - // must not reappear in the list. - if model.category != v1::ModelCategory::Language as i32 { + // must not reappear in the list. LOCAL(decision): decision models are + // part of the list, matching catalog.rs::is_llm. + if model.category != v1::ModelCategory::Language as i32 + && model.category != v1::ModelCategory::Decision as i32 + { continue; } let key = crate::catalog::merge_key_for(&model.id); @@ -209,7 +215,78 @@ fn settle_row_id_and_size(row: &mut GroupedRow) { } } -fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { +// A list command must not sit on a dead network; the cached list is the +// fallback. +const CLOUD_LOOKUP_TIMEOUT_MS: i32 = 3_000; + +/// Where the cloud rows came from. +enum CloudModels { + Live(Vec), + /// The console could not be reached; these are the ids the last + /// successful lookup saved (`account::cached_model_ids`). + Cached(Vec), +} + +/// The hosted models the signed-in account can use, or why there are none to +/// show. Ordered as the console lists them. +fn cloud_models() -> Result { + let mut session = ConsoleSession::open(ConsoleClient::default())?; + match session + .call(|client, url, token| client.fetch_models_within(url, token, CLOUD_LOOKUP_TIMEOUT_MS)) + { + Ok(models) => Ok(CloudModels::Live( + models + .into_iter() + .map(|model| model.id) + .filter(|id| !id.is_empty()) + .collect(), + )), + Err(reason) => { + let cached = crate::account::cached_model_ids(); + if cached.is_empty() { + Err(reason) + } else { + Ok(CloudModels::Cached(cached)) + } + } + } +} + +/// The `[cloud]` tag, blue where stdout takes color. +fn cloud_tag(no_color: bool) -> String { + let color = !no_color + && crate::util::term::stdout_is_tty() + && crate::util::getenv("NO_COLOR").is_none(); + if color { + "\x1b[34m[cloud]\x1b[0m".to_string() + } else { + "[cloud]".to_string() + } +} + +/// Which rows `models list` shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Scope { + Downloaded, + Local, + Cloud, + All, +} + +impl Scope { + fn local_rows(self) -> bool { + self != Scope::Cloud + } + fn whole_catalog(self) -> bool { + matches!(self, Scope::Local | Scope::All) + } + fn cloud_rows(self) -> bool { + matches!(self, Scope::Cloud | Scope::All) + } +} + +fn run_list(options: &GlobalOptions, scope: Scope) -> i32 { + let show_all = scope.whole_catalog(); let Ok(_env) = bootstrap(options) else { return 1; }; @@ -259,7 +336,37 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { } } - let (order, groups) = group_models(&all_models.models, &downloaded_ids, show_all); + let (order, groups) = if scope.local_rows() { + group_models(&all_models.models, &downloaded_ids, show_all) + } else { + (Vec::new(), HashMap::new()) + }; + + // Under --all a failure to reach the cloud never fails the local list; it + // is one line on stderr saying why they are missing. Under --cloud the + // cloud is all that was asked for, so the same failure is the error. + let mut cloud_ids: Vec = Vec::new(); + if scope.cloud_rows() { + match cloud_models() { + Ok(CloudModels::Live(ids)) => cloud_ids = ids, + Ok(CloudModels::Cached(ids)) => { + if !options.json { + out::status_line( + "could not reach Wally Cloud; cloud models are from the last time wally did", + ); + } + cloud_ids = ids; + } + Err(reason) if scope == Scope::Cloud => { + out::error_line(&format!("could not list cloud models: {reason}")); + return 1; + } + Err(reason) if !options.json => { + out::status_line(&format!("cloud models not shown: {reason}")); + } + Err(_) => {} + } + } if options.json { let mut json = out::JsonWriter::new(); @@ -278,6 +385,20 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { // the group is downloaded (mirrors the pre-merge shape, which // callers already treat "" as "not downloaded"). .field_str("local_path", &row.local_path) + .field_bool("cloud", false) + .end_object(); + } + for id in &cloud_ids { + json.begin_array_object() + .field_str("id", id) + .field_str("name", id) + .field_str("modality", "llm") + .field_str("backend", "cloud") + .field_i64("size_bytes", 0) + .field_bool("downloaded", false) + .field_bool("harness_compatible", true) + .field_str("local_path", "") + .field_bool("cloud", true) .end_object(); } json.end_array().end_object(); @@ -285,7 +406,9 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { return 0; } - print_pull_examples(); + if scope.local_rows() { + print_pull_examples(); + } let mut rows: Vec> = Vec::new(); for key in &order { @@ -309,11 +432,25 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { ]); } + let tag = cloud_tag(options.no_color); + for id in &cloud_ids { + rows.push(vec![ + id.clone(), + "llm".to_string(), + "cloud".to_string(), + "-".to_string(), + "-".to_string(), + tag.clone(), + ]); + } + if rows.is_empty() { - out::result_line(if show_all { + out::result_line(if scope == Scope::Cloud { + "no cloud models on this account" + } else if show_all { "no models registered" } else { - "no models downloaded — try `wally models list --all` then `wally models pull `" + "no models downloaded — try `wally models list --local` then `wally models pull `" }); return 0; } @@ -324,12 +461,33 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { 0 } +/// The scope the flags ask for, or why they contradict each other. +fn list_scope(all: bool, local: bool, cloud: bool) -> Result { + match (all, local, cloud) { + (false, false, false) => Ok(Scope::Downloaded), + (true, false, false) => Ok(Scope::All), + (false, true, false) => Ok(Scope::Local), + (false, false, true) => Ok(Scope::Cloud), + _ => Err("pick one of --all, --local or --cloud"), + } +} + pub fn configure_models_list(cmd: &mut App) { - cmd.add_flag("--all,-a", "Include catalog models not yet downloaded"); - cmd.callback(|p, g| { - let show_all = p.flag("--all"); - run_list(g, show_all) - }); + cmd.add_flag( + "--all,-a", + "Every local catalog model, plus your account's cloud models", + ); + cmd.add_flag("--local", "Every local catalog model, without cloud models"); + cmd.add_flag("--cloud", "Only your account's cloud models"); + cmd.callback( + |p, g| match list_scope(p.flag("--all"), p.flag("--local"), p.flag("--cloud")) { + Ok(scope) => run_list(g, scope), + Err(message) => { + out::error_line(message); + 2 + } + }, + ); } #[cfg(test)] @@ -467,4 +625,24 @@ mod tests { let (_, groups) = group_models(&models, &downloaded, true); assert_eq!(groups["qwen3-4b-instruct-2507"].size_bytes, 0); } + + #[test] + fn list_scope_maps_one_flag_to_one_scope() { + assert_eq!(list_scope(false, false, false), Ok(Scope::Downloaded)); + assert_eq!(list_scope(true, false, false), Ok(Scope::All)); + assert_eq!(list_scope(false, true, false), Ok(Scope::Local)); + assert_eq!(list_scope(false, false, true), Ok(Scope::Cloud)); + } + + #[test] + fn list_scope_refuses_two_flags_at_once() { + assert!(list_scope(true, true, false).is_err()); + assert!(list_scope(false, true, true).is_err()); + assert!(list_scope(true, false, true).is_err()); + } + + #[test] + fn cloud_tag_is_plain_when_color_is_off() { + assert_eq!(cloud_tag(true), "[cloud]"); + } } diff --git a/src/commands/cmd_maintenance.rs b/src/commands/cmd_maintenance.rs index 41ec06e1..117ec275 100644 --- a/src/commands/cmd_maintenance.rs +++ b/src/commands/cmd_maintenance.rs @@ -104,22 +104,21 @@ fn platform_base() -> String { } // The on-device model store, matching harness/local_models.rs: {base}/Models -// or {base}/RunAnywhere/Models. Every base it can derive is checked -- the -// global --home override, the env override, the kit's answer, and the -// platform default -- and the first store that actually exists wins, so a -// missing bootstrap cannot hide it. +// or {base}/RunAnywhere/Models. An explicit --home takes precedence over +// RUNANYWHERE_HOME, and either override confines the search to that base, +// even when its model store is absent. With neither override, check the +// kit's answer and the platform default so a missing bootstrap cannot hide it. fn models_directory(home_override: &str) -> String { let mut bases: Vec = Vec::new(); - if let Some(env) = getenv("RUNANYWHERE_HOME") { - bases.push(env); - } let home = cli_paths::resolve_home(home_override); if !home.is_empty() { bases.push(home); } - let fallback = platform_base(); - if !fallback.is_empty() { - bases.push(fallback); + if home_override.is_empty() && getenv("RUNANYWHERE_HOME").is_none() { + let fallback = platform_base(); + if !fallback.is_empty() { + bases.push(fallback); + } } for base in &bases { @@ -324,22 +323,14 @@ fn unix_exe_targets(exe: &str) -> Vec { /// `home_override` is the global `--home` flag, so uninstall removes the same /// model store the other model commands are pointed at, not just the default /// one. -pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { +/// +/// Downloaded models are kept unless the person says otherwise: the store is +/// shared with other RunAnywhere apps, and it is the one thing here that is +/// slow to get back. Interactively they are asked separately; under `--yes` +/// only `--delete-models` removes them. +pub fn run_uninstall(yes: bool, delete_models: bool, home_override: &str) -> i32 { let mut targets: Vec = Vec::new(); - // Windows cannot delete the program file backing its own running - // process (unlike a Unix inode, which stays live under an unlinked - // name until the process exits) -- see the note below, where this is - // reported instead of attempted. - #[cfg(windows)] - let mut manual_removal: Option = None; - let models = models_directory(home_override); - if !models.is_empty() { - targets.push(Target { - label: "models", - path: PathBuf::from(models), - }); - } let config = credentials::profile_directory(); if !config.is_empty() { targets.push(Target { @@ -348,25 +339,20 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { }); } + // Windows cannot delete the program files backing its own running + // process, so the install folder is handed to a helper that removes it + // once this process has exited (see remove_after_exit). + #[cfg(windows)] + let mut windows_install: Option = None; let exe = self_executable(); if !exe.is_empty() { - // A running exe cannot be deleted on Windows. One that install.ps1 - // put down is named for the person to remove; any other (a source - // build) is left alone, as the C++ build always did there. #[cfg(windows)] { - if let Some(dir) = windows_install_directory(&exe) { - manual_removal = Some(dir); - } + windows_install = windows_install_directory(&exe); } #[cfg(not(windows))] { targets.extend(unix_exe_targets(&exe)); - // Only install.sh's own tree is ever deleted (unix_exe_targets - // above); a Homebrew install or an unverified (e.g. source-build) - // location is reported instead, the same way the Windows branch - // only reports manual_removal rather than deleting an unverified - // binary. if install_sh_layout(&exe).is_none() { if crate::commands::cmd_update::is_homebrew_managed(&exe) { out::status_line( @@ -383,54 +369,58 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { } let present: Vec = targets.into_iter().filter(|t| t.path.exists()).collect(); + let models = models_directory(home_override); + let models = (!models.is_empty()) + .then(|| PathBuf::from(models)) + .filter(|path| path.exists()); #[cfg(windows)] - let has_manual_removal = manual_removal.is_some(); + let has_install = windows_install.is_some(); #[cfg(not(windows))] - let has_manual_removal = false; + let has_install = false; - if present.is_empty() && !has_manual_removal { + if present.is_empty() && !has_install && models.is_none() { out::status_line("nothing to uninstall; wally is already gone."); return 0; } - if !present.is_empty() { + // Models are listed with everything else only when --delete-models + // already chose them; otherwise they get a question of their own. + let models_listed = models.as_ref().filter(|_| delete_models); + let models_asked = models.as_ref().filter(|_| !delete_models); + let has_listed = !present.is_empty() || has_install || models_listed.is_some(); + if has_listed { out::status_line("wally uninstall will delete:"); for target in &present { - let size = human_size(&target.path); - let size_suffix = if size.is_empty() { - String::new() - } else { - format!(" ({size})") - }; out::status_line(&format!( - " {} {}{size_suffix}", + " {} {}{}", target.label, - target.path.display() + target.path.display(), + size_suffix(&target.path) + )); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + out::status_line(&format!(" install {}{}", dir.display(), size_suffix(dir))); + } + if let Some(models) = models_listed { + out::status_line(&format!( + " models {}{}", + display_path(models), + size_suffix(models) )); } - } - #[cfg(windows)] - if let Some(dir) = &manual_removal { - // Windows will not let a running process delete (or even rename) its - // own program file, so there is no safe way to have this process - // finish that part of the job itself. Spawning a detached helper - // that waits on our pid and deletes behind us is the other option - // the C++ never had either, but it trades one guaranteed, honest - // message for a background process that can be killed, blocked by - // antivirus, or race a relaunch of wally -- worse failure modes than - // telling the person the one folder left to remove by hand. - out::status_line(&format!( - "wally cannot delete its own running program on Windows; close this window, \ - then delete {} yourself to finish uninstalling.", - dir.display() - )); } out::status_line("your coding tools (claude-code, opencode, ...) are left untouched."); - if !present.is_empty() && !yes { - // Never delete without a real confirmation. A non-interactive shell - // (piped or redirected stdin) cannot answer, so it must pass --yes on - // purpose rather than have the prompt silently skipped. + let mut remove_models = models_listed.is_some(); + if yes { + if let Some(models) = models_asked { + out::status_line(&format!( + "kept your downloaded models in {} (pass --delete-models to delete them)", + display_path(models) + )); + } + } else { if !term::stdin_is_tty() { out::error_line( "uninstall needs a terminal to confirm; re-run with --yes to delete \ @@ -438,10 +428,18 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { ); return 1; } - if !confirm("delete all of the above?") { + if has_listed && !confirm("delete all of the above?") { out::status_line("aborted; nothing was deleted."); return 1; } + if let Some(models) = models_asked { + out::status_line(&format!( + "\nyour downloaded models: {}{}", + display_path(models), + size_suffix(models) + )); + remove_models = confirm("delete them too?"); + } } // Everything but the running binary (or the tree containing it) first; @@ -451,37 +449,43 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { // tree out from under it. let mut failures = 0; let mut deferred: Option = None; - for target in &present { - if target.label == "install" { - deferred = Some(target.path.clone()); - continue; + let mut removals: Vec<&Path> = present.iter().map(|t| t.path.as_path()).collect(); + if remove_models { + if let Some(models) = &models { + removals.push(models.as_path()); } - if let Err(e) = remove_all(&target.path) { - out::error_line(&format!( - "could not delete {}: {}", - target.path.display(), - os_error_message(&e) - )); - failures += 1; + } + for path in removals { + if present + .iter() + .any(|t| t.label == "install" && t.path == path) + { + deferred = Some(path.to_path_buf()); + continue; } + failures += report_removal(path); } if let Some(deferred) = deferred { - if let Err(e) = remove_all(&deferred) { + failures += report_removal(&deferred); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + if remove_after_exit(dir) { + out::status_line(&format!( + "{} is removed as soon as this command exits.", + dir.display() + )); + } else { out::error_line(&format!( - "could not delete {}: {}", - deferred.display(), - os_error_message(&e) + "could not schedule removal of {}; delete it yourself after closing wally.", + dir.display() )); failures += 1; } } + // Reported above already; counted here so a script checking the exit // code sees an incomplete uninstall rather than a clean 0. - #[cfg(windows)] - if has_manual_removal { - failures += 1; - } - if failures != 0 { return 1; } @@ -489,6 +493,77 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { 0 } +/// `path` with one separator style: the models directory is built with `/` +/// while the Windows paths around it use `\`. +fn display_path(path: &Path) -> String { + let text = path.display().to_string(); + if cfg!(windows) { + text.replace('/', "\\") + } else { + text + } +} + +fn size_suffix(path: &Path) -> String { + let size = human_size(path); + if size.is_empty() { + String::new() + } else { + format!(" ({size})") + } +} + +/// Deletes `path`, printing why when it cannot. Returns the failure count. +fn report_removal(path: &Path) -> i32 { + match remove_all(path) { + Ok(()) => 0, + Err(e) => { + out::error_line(&format!( + "could not delete {}: {}", + path.display(), + os_error_message(&e) + )); + 1 + } + } +} + +/// Starts a hidden PowerShell that waits for this process to exit, deletes +/// `dir`, and drops it from the user's PATH (install.ps1 added it there). The +/// helper is a separate process because Windows will not delete the files of +/// a running program. The paths reach PowerShell as environment variables, +/// never spliced into the command. +#[cfg(windows)] +fn remove_after_exit(dir: &Path) -> bool { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let script = r#"Wait-Process -Id $env:WALLY_UNINSTALL_PID -ErrorAction SilentlyContinue +for ($i = 0; $i -lt 20 -and (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR); $i++) { + Remove-Item -LiteralPath $env:WALLY_UNINSTALL_DIR -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR) { Start-Sleep -Milliseconds 250 } +} +$entries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $env:WALLY_UNINSTALL_DIR }) +[Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User')"#; + std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ]) + .arg(script) + .env("WALLY_UNINSTALL_PID", std::process::id().to_string()) + .env("WALLY_UNINSTALL_DIR", dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) + .spawn() + .is_ok() +} + /// `wally help [command]`. Renders the same text `app.get_subcommand(topic) /// ->help(app.get_name())` would in C++ for a known `topic`; with no topic, /// or an unknown one, C++ falls back to `app.help()` -- which CLI11 @@ -544,7 +619,17 @@ pub fn register_help(app: &mut App) -> Rc>> { pub fn register_uninstall(app: &mut App) { let cmd = app.add_subcommand("uninstall", "Remove wally, its models and its config"); cmd.add_flag("-y,--yes", "Skip the confirmation prompt"); - cmd.callback(|parsed, options| run_uninstall(parsed.flag("--yes"), &options.home_override)); + cmd.add_flag( + "--delete-models", + "Also delete downloaded models (kept by default)", + ); + cmd.callback(|parsed, options| { + run_uninstall( + parsed.flag("--yes"), + parsed.flag("--delete-models"), + &options.home_override, + ) + }); } #[cfg(test)] @@ -552,6 +637,41 @@ mod tests { use super::*; use crate::util::env_lock::lock as env_lock; + struct RestoreEnv(Vec<(&'static str, Option)>); + + impl Drop for RestoreEnv { + fn drop(&mut self) { + for (name, value) in &self.0 { + // SAFETY: callers hold env_lock until with_storage_env returns. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } + } + + // Callers hold env_lock for the whole test, including tempfile creation. + fn with_storage_env(vars: &[(&'static str, Option<&Path>)], body: impl FnOnce() -> T) -> T { + let _restore = RestoreEnv( + vars.iter() + .map(|(name, _)| (*name, std::env::var_os(name))) + .collect(), + ); + for (name, value) in vars { + // SAFETY: env_lock is held until after the previous values are restored. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + body() + } + // Dir_size (uninstall's pre-delete size preview) must follow a // symlink to a regular file, matching C++'s is_regular_file(ec)/ // file_size(ec) (which call status(), following symlinks). @@ -626,27 +746,104 @@ mod tests { // always resolve against HOME/RUNANYWHERE_HOME, so it could remove a // different model store than the one `wally models` was just pointed at. #[test] - #[cfg(unix)] fn models_directory_prefers_the_home_override_over_home() { let _lock = env_lock(); let home_a = tempfile::tempdir().expect("tempdir A"); let home_b = tempfile::tempdir().expect("tempdir B"); std::fs::create_dir_all(home_b.path().join("Models")).expect("mkdir B/Models"); - // SAFETY: env_lock() is held for this whole test body. - unsafe { - std::env::set_var("HOME", home_a.path()); - std::env::remove_var("RUNANYWHERE_HOME"); + + with_storage_env( + &[("HOME", Some(home_a.path())), ("RUNANYWHERE_HOME", None)], + || { + assert_eq!( + PathBuf::from(models_directory(&home_b.path().to_string_lossy())), + home_b.path().join("Models") + ); + }, + ); + } + + #[test] + fn models_directory_prefers_home_override_over_runanywhere_home() { + let _lock = env_lock(); + for layout in ["Models", "RunAnywhere/Models"] { + let explicit = tempfile::tempdir().expect("explicit home"); + let from_env = tempfile::tempdir().expect("environment home"); + let expected = explicit.path().join(layout); + std::fs::create_dir_all(&expected).expect("explicit model store"); + std::fs::create_dir_all(from_env.path().join("Models")) + .expect("environment model store"); + + with_storage_env(&[("RUNANYWHERE_HOME", Some(from_env.path()))], || { + assert_eq!( + PathBuf::from(models_directory(&explicit.path().to_string_lossy())), + expected + ); + }); } + } - let result = models_directory(&home_b.path().to_string_lossy()); + #[test] + fn models_directory_does_not_fall_back_when_home_override_has_no_models() { + let _lock = env_lock(); + let explicit = tempfile::tempdir().expect("explicit home"); + let from_env = tempfile::tempdir().expect("environment home"); + let default_home = tempfile::tempdir().expect("default home"); + std::fs::create_dir_all(from_env.path().join("Models")).expect("environment model store"); + + for env_home in [Some(from_env.path()), None] { + with_storage_env( + &[ + ("HOME", Some(default_home.path())), + ("XDG_DATA_HOME", Some(default_home.path())), + ("RUNANYWHERE_HOME", env_home), + ], + || { + std::fs::create_dir_all(Path::new(&platform_base()).join("Models")) + .expect("default model store"); + assert_eq!( + PathBuf::from(models_directory(&explicit.path().to_string_lossy())), + explicit.path().join("Models") + ); + }, + ); + } + } - // SAFETY: still holding env_lock(). - unsafe { std::env::remove_var("HOME") }; + #[test] + fn models_directory_uses_runanywhere_home_without_a_home_override() { + let _lock = env_lock(); + for layout in ["Models", "RunAnywhere/Models"] { + let from_env = tempfile::tempdir().expect("environment home"); + let expected = from_env.path().join(layout); + std::fs::create_dir_all(&expected).expect("environment model store"); + + with_storage_env(&[("RUNANYWHERE_HOME", Some(from_env.path()))], || { + assert_eq!(PathBuf::from(models_directory("")), expected); + }); + } + } - assert!( - result.starts_with(home_b.path().to_str().expect("utf8 path")), - "models_directory({home_b:?}) must resolve against the override, not HOME \ - ({home_a:?}): got {result}" + #[test] + fn models_directory_does_not_fall_back_when_runanywhere_home_has_no_models() { + let _lock = env_lock(); + let from_env = tempfile::tempdir().expect("environment home"); + let default_home = tempfile::tempdir().expect("default home"); + + with_storage_env( + &[ + ("HOME", Some(default_home.path())), + ("XDG_DATA_HOME", Some(default_home.path())), + ("RUNANYWHERE_HOME", Some(from_env.path())), + ], + || { + std::fs::create_dir_all(Path::new(&platform_base()).join("Models")) + .expect("default model store"); + assert_eq!( + PathBuf::from(models_directory("")), + from_env.path().join("Models") + ); + }, ); } diff --git a/src/commands/cmd_run.rs b/src/commands/cmd_run.rs index 6fbadab6..42512bf3 100644 --- a/src/commands/cmd_run.rs +++ b/src/commands/cmd_run.rs @@ -732,6 +732,7 @@ fn print_repl_help() { status_line(" /set max-output-tokens set the generation budget"); status_line(" /show show current settings"); status_line(" /bye exit (also Ctrl-D)"); + status_line("Ctrl-C cancels the current turn"); status_line("note: turns are independent — no conversation memory yet"); } diff --git a/src/commands/cmd_serve.rs b/src/commands/cmd_serve.rs index 3b4de27d..9e145236 100644 --- a/src/commands/cmd_serve.rs +++ b/src/commands/cmd_serve.rs @@ -86,6 +86,12 @@ fn run_serve( return 1; } + // Decision checkpoints get their own endpoint: rac_server is LLM-only, + // so a decision model reference serves POST /v1/decisions in-process. + if !reference.is_empty() && is_decision_reference(reference) { + return run_serve_decisions(options, reference, host, port); + } + let model = match ensure_model_ready( options, if reference.is_empty() { @@ -208,6 +214,269 @@ fn run_serve( exit_code } +/// A catalog id or alias naming an on-disk decision model (clef rows). Paths +/// to GGUF files are not covered: they skip the catalog and stay on the +/// rac_server path, which refuses them the way it always has. +#[cfg(wally_has_server)] +fn is_decision_reference(reference: &str) -> bool { + matches!( + crate::catalog::find(reference), + Some(entry) if entry.category == crate::io::proto::v1::ModelCategory::Decision + ) +} + +/// Same 1 MiB cap the CLI enforces on request bodies. +#[cfg(wally_has_server)] +const MAX_SERVE_BODY_BYTES: usize = 1024 * 1024; + +/// Split an HTTP/1 head into method, path (query stripped) and content +/// length. Header names match case-insensitively; anything unreadable is a +/// 400 with the reason. +#[cfg(wally_has_server)] +fn parse_head(head: &[u8]) -> Result<(String, String, usize), String> { + let text = std::str::from_utf8(head).map_err(|_| "unreadable request head".to_string())?; + let mut lines = text.split("\r\n"); + let request_line = lines.next().ok_or_else(|| "empty request".to_string())?; + let mut parts = request_line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| "bad request line".to_string())? + .to_string(); + let target = parts.next().ok_or_else(|| "bad request line".to_string())?; + let path = target.split('?').next().unwrap_or(target).to_string(); + let mut length: usize = 0; + for line in lines { + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.trim().eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value) + { + length = value + .trim() + .parse() + .map_err(|_| "bad content-length".to_string())?; + } + } + Ok((method, path, length)) +} + +#[cfg(wally_has_server)] +fn write_response(stream: &mut std::net::TcpStream, status: u16, reason: &str, body: &str) { + use std::io::Write; + let _ = write!( + stream, + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.flush(); +} + +#[cfg(wally_has_server)] +fn json_error(message: &str) -> String { + let mut json = crate::io::output::JsonWriter::new(); + json.begin_object().field_str("error", message).end_object(); + json.str().to_string() +} + +/// One connection, served synchronously: the decision handle is not shared +/// across threads, so requests queue instead of racing inside the engine. +#[cfg(wally_has_server)] +fn handle_decisions_connection( + stream: &mut std::net::TcpStream, + loaded: &crate::commands::cmd_decisions::LoadedDecisions, +) { + use std::io::Read; + let mut raw: Vec = Vec::new(); + let mut buf = [0u8; 4096]; + let head_end = loop { + match stream.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + raw.extend_from_slice(&buf[..n]); + if raw.len() > 65536 { + write_response( + stream, + 431, + "Request Header Fields Too Large", + &json_error("headers too large"), + ); + return; + } + if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + } + Err(_) => return, + } + }; + let (method, path, length) = match parse_head(&raw[..head_end]) { + Ok(parsed) => parsed, + Err(message) => { + write_response(stream, 400, "Bad Request", &json_error(&message)); + return; + } + }; + if path == "/health" && method == "GET" { + let mut json = crate::io::output::JsonWriter::new(); + json.begin_object().field_str("status", "ok").end_object(); + write_response(stream, 200, "OK", json.str()); + return; + } + if method != "POST" || path != "/v1/decisions" { + write_response(stream, 404, "Not Found", &json_error("not found")); + return; + } + if length > MAX_SERVE_BODY_BYTES { + write_response( + stream, + 413, + "Content Too Large", + &json_error("request body exceeds 1 MiB"), + ); + return; + } + let mut body = raw[head_end..].to_vec(); + while body.len() < length { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => body.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + if body.len() < length { + write_response( + stream, + 400, + "Bad Request", + &json_error("truncated request body"), + ); + return; + } + body.truncate(length); + let value: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + write_response( + stream, + 400, + "Bad Request", + &json_error("invalid request JSON"), + ); + return; + } + }; + let unknown = crate::commands::cmd_decisions::unknown_request_fields(&value); + if !unknown.is_empty() { + write_response( + stream, + 400, + "Bad Request", + &json_error(&format!( + "request has fields the decisions contract does not define: {}", + unknown.join(", ") + )), + ); + return; + } + let request = match crate::account::decisions_contract::DecisionsRequest::from_json(&value) { + Ok(request) => request, + Err(error) => { + write_response( + stream, + 400, + "Bad Request", + &json_error(&format!( + "request does not match the decisions contract: {error}" + )), + ); + return; + } + }; + if let Err(error) = crate::commands::cmd_decisions::validate(&request) { + write_response(stream, 400, "Bad Request", &json_error(&error)); + return; + } + // The served model answers every request; a body naming another model is + // scored by this server's model, the way `serve` owns its one model. + match crate::commands::cmd_decisions::score_loaded(loaded, &request) { + Ok(response) => { + write_response( + stream, + 200, + "OK", + &crate::io::json::dump(&response.to_json()), + ); + } + Err(message) => { + write_response(stream, 500, "Internal Server Error", &json_error(&message)); + } + } +} + +/// Serve a local decision checkpoint over HTTP: one model loaded once, then +/// POST /v1/decisions per request plus GET /health. Mirrors the CLI's +/// validation so the same body behaves the same in both. +#[cfg(wally_has_server)] +fn run_serve_decisions(options: &GlobalOptions, reference: &str, host: &str, port: u16) -> i32 { + if bootstrap::bootstrap(options).is_err() { + return 1; + } + + let model = match ensure_model_ready(options, reference) { + Ok(model) => model, + Err(code) => return code, + }; + let loaded = match crate::commands::cmd_decisions::load_decisions_component(&model) { + Ok(loaded) => loaded, + Err(message) => { + error_line(&message); + return 1; + } + }; + + let listener = match std::net::TcpListener::bind((host, port)) { + Ok(listener) => listener, + Err(error) => { + error_line(&format!("could not bind {host}:{port}: {error}")); + return 1; + } + }; + if listener.set_nonblocking(true).is_err() { + error_line("could not set the listener non-blocking"); + return 1; + } + + status_line(&format!( + "serving {} (decision model, single model)", + model.model_id + )); + status_line(&format!("Decisions API: http://{host}:{port}/v1/decisions")); + status_line(&format!("health: http://{host}:{port}/health")); + status_line("Ctrl-C to stop"); + + SERVE_STOP.store(false, Ordering::SeqCst); + let _interrupt = crate::util::interrupt::on_interrupt(serve_signal_handler); + loop { + if SERVE_STOP.load(Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); + handle_decisions_connection(&mut stream, &loaded); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(error) => { + error_line(&format!("accept failed: {error}")); + break; + } + } + } + 0 +} + pub fn register_serve(app: &mut App) { let cmd = app.add_subcommand("serve", "Serve a model over an OpenAI-compatible API"); #[cfg(wally_has_server)] @@ -313,4 +582,38 @@ mod tests { assert_eq!(config.max_concurrent_requests, 4); assert_eq!(config.verbose, sys::RAC_FALSE as sys::rac_bool_t); } + + #[test] + fn head_parses_method_path_and_length() { + let (method, path, length) = + parse_head(b"POST /v1/decisions?x=1 HTTP/1.1\r\nHost: a\r\nContent-Length: 12\r\n\r\n") + .expect("head parses"); + assert_eq!(method, "POST"); + assert_eq!(path, "/v1/decisions"); + assert_eq!(length, 12); + } + + #[test] + fn head_rejects_garbage() { + assert!(parse_head(b"").is_err()); + assert!(parse_head(b"GET\r\n\r\n").is_err()); + assert!(parse_head(b"POST /x HTTP/1.1\r\nContent-Length: lots\r\n\r\n").is_err()); + } + + #[test] + fn decision_ids_route_to_the_decisions_server() { + // Catalog rows are platform-gated (Mlx on macOS only, LlamaCpp where + // linked), so the routing test asserts per-build truth, not macOS truth. + assert_eq!( + is_decision_reference("clef-flash-mlx"), + cfg!(target_os = "macos") + ); + assert_eq!( + is_decision_reference("clef-flash-9b"), + cfg!(wally_has_llamacpp) + ); + assert!(!is_decision_reference("qwen3-4b-instruct-2507")); + assert!(!is_decision_reference("eve")); + assert!(!is_decision_reference("no-such-model")); + } } diff --git a/src/commands/cmd_update.rs b/src/commands/cmd_update.rs index 38757841..87662c1c 100644 --- a/src/commands/cmd_update.rs +++ b/src/commands/cmd_update.rs @@ -6,16 +6,35 @@ //! it cannot know on its own is what the caller is running. So update hands it //! this build's version, and the script decides: pull the newer release, or //! report that this machine is already current and download nothing. +//! +//! The same script answers a check with the latest release version alone: +//! `install.sh --check`, or `install.ps1` under `WALLY_INSTALL_CHECK=1` (it is +//! run as `irm | iex`, which passes no arguments). The one-line +//! update-available notice reads that, so the version lookup lives in one +//! place. use crate::cli::App; use crate::io::output as out; -#[cfg(not(windows))] use crate::util::getenv; // The same script the install line in the README pipes to a shell. Kept as one // constant so the update path and the documented install path cannot drift. #[cfg(not(windows))] const INSTALL_URL: &str = "https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.sh"; +#[cfg(windows)] +const INSTALL_URL: &str = "https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.ps1"; + +// Points update and the update check at another copy of the installer, for +// release tests and mirrors, the way the installer's own WALLY_INSTALL_BASE_URL +// points it at another copy of the release. +const SCRIPT_URL_ENV: &str = "WALLY_UPDATE_SCRIPT_URL"; + +// How long a version lookup is trusted before the notice asks again. +const CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +// Bounds the lookup so a dead network cannot hold a version check, or +// leave a background check running, for long. +const CHECK_TIMEOUT_SECS: &str = "10"; // Homebrew's own prefixes, plus whatever `$HOMEBREW_PREFIX` names (set by // `brew shellenv` and by every formula's build environment). A binary @@ -48,28 +67,18 @@ pub(crate) fn is_homebrew_managed(exe: &str) -> bool { pub fn register_update(app: &mut App) { let cmd = app.add_subcommand("update", "Update wally to the latest release"); - cmd.add_flag("--nightly", "Track the development channel"); - cmd.callback(|parsed, _options| { - if run_update(parsed.flag("--nightly")) != 0 { + cmd.callback(|_parsed, _options| { + let code = run_update(); + if code != 0 { return 1; } 0 }); } -/// Shared by `wally update` and the whole-argv `-u/--update` shortcut. -#[cfg(windows)] -pub fn run_update(nightly: bool) -> i32 { - let _ = nightly; - // The installer is a POSIX shell script; the Windows bottle updates - // through its own channel, not this command. - out::error_line("wally update is not available on Windows; reinstall from the release page"); - 1 -} - /// Shared by `wally update` and the whole-argv `-u/--update` shortcut. #[cfg(not(windows))] -pub fn run_update(nightly: bool) -> i32 { +pub fn run_update() -> i32 { let exe = crate::commands::cmd_maintenance::self_executable(); if is_homebrew_managed(&exe) { // Not a failure: the command's job -- get the person to the newest @@ -81,21 +90,51 @@ pub fn run_update(nightly: bool) -> i32 { return 0; } - // WALLY_VERSION is a compile-time constant and the flag is a fixed token, - // so the command line carries nothing a caller could inject. - let mut command = format!("curl -fsSL {INSTALL_URL} | sh -s --"); - if nightly { - command.push_str(" --nightly"); - } - command.push_str(" --version="); - command.push_str(env!("WALLY_VERSION")); + // The script URL and the installer's arguments reach the shell as + // positional parameters, never spliced into the command string, so an + // override URL carries nothing a caller could inject. + let installer_args = [format!("--version={}", env!("WALLY_VERSION"))]; out::status_line("checking for a newer wally..."); // A non-zero exit means the installer already explained why on its own // stderr; a failure to even spawn `sh` counts the same way. - let ok = std::process::Command::new("sh") - .arg("-c") - .arg(&command) + let ok = installer_command(&installer_args, None) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if ok { + 0 + } else { + 1 + } +} + +/// Shared by `wally update` and the whole-argv `-u/--update` shortcut. +/// +/// install.ps1 replaces the running wally.exe and its DLLs file by file +/// (Windows renames a file in use but will not move or delete it), so the +/// installer runs while this process waits, the same as on macOS and Linux. +#[cfg(windows)] +pub fn run_update() -> i32 { + let current = env!("WALLY_VERSION"); + out::status_line("checking for a newer wally..."); + let Some(latest) = fetch_latest_version() else { + out::error_line("could not check for a newer wally; check your connection and try again"); + return 1; + }; + write_cached_latest(&latest); + if !is_newer(&latest, current) { + out::status_line(&format!("wally {current} is the latest")); + return 0; + } + // The URL reaches PowerShell as an environment variable, never spliced + // into the command, so an override URL carries nothing a caller could + // inject. A non-zero exit means the installer already said why. + let script = r#"$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Invoke-Expression (Invoke-RestMethod -UseBasicParsing $env:WALLY_UPDATE_RESOLVED_URL)"#; + let ok = powershell(script) + .env_remove("WALLY_INSTALL_CHECK") .status() .map(|status| status.success()) .unwrap_or(false); @@ -106,6 +145,267 @@ pub fn run_update(nightly: bool) -> i32 { } } +/// Deletes the `.old` files an update left beside wally.exe because +/// they were still in use. Best effort: one still in use stays for next time. +#[cfg(windows)] +pub fn sweep_replaced_files() { + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(dir) = exe.parent() else { return }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().ends_with(".old") { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +#[cfg(not(windows))] +pub fn sweep_replaced_files() {} + +fn install_script_url() -> String { + getenv(SCRIPT_URL_ENV) + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| INSTALL_URL.to_string()) +} + +/// `sh -c 'curl