Skip to content

fix(chat): apr chat could not open a local model — every path with a slash became hf://<path> - #2413

Closed
noahgift wants to merge 2 commits into
mainfrom
fix/chat-local-model-path
Closed

fix(chat): apr chat could not open a local model — every path with a slash became hf://<path>#2413
noahgift wants to merge 2 commits into
mainfrom
fix/chat-local-model-path

Conversation

@noahgift

@noahgift noahgift commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline on the
crates.io 0.63.0 binary never looked at the file. It rewrote the absolute path
into a HuggingFace repo id and went to the network, which --offline did not
stop either:

$ apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline
Downloading hf:///home...
  Downloading model.safetensors...
error: Validation failed: Download failed: https://huggingface.co//home/resolve/main/model.safetensors: status code 404
rc=5

The same three fixtures (0.5B .apr, 1.5B q4k .apr, 0.8B .gguf) all produced
Downloading hf:///home..., and apr run opened all of them. Local models were
simply unreachable from apr chat.

Root cause: chat carried its own copy of the model-source resolution instead of
using run's. crates/apr-cli/src/commands/chat.rs:116 read

let hf_uri = if !resolved_source.contains("://") && resolved_source.contains('/') {
    format!("hf://{resolved_source}")

with none of the local-path guards run::resolve_model_source has
(!Path::new(..).exists(), !starts_with('/')), so any argument containing a
slash became a repo id. chat.rs:126 then called
resolve_model(&model_source, false, false) with offline hard-coded to
false, which is why --offline could not stop the request.

chat now calls run::resolve_model_source and threads the real --offline
through to resolve_model, so a local path is a local path in both commands and
--offline refuses uncached repos locally.

The second half is in run.rs. resolve_model_source decided correctly that an
existing relative path like models/tiny.gguf was local, then handed it to
pull::resolve_hf_model, whose normalize_hf_uri re-prepends hf:// to any
slash-bearing scheme-less argument — undoing the decision. That call is now made
only for arguments that are already hf:// references. apr run had the same
defect for relative paths and is fixed by the same line.

After, with a release binary built from this branch:

$ apr chat /home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf --offline </dev/null
=== Model Chat (GGUF Format) ===
  Model: /home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf
Loaded GGUF format in 0.27s (532.5 MB)
Loaded tokenizer with 248320 tokens
rc=0

$ apr chat /home/noah/models/qwen2.5-coder-1.5b-instruct-q4k.apr --offline </dev/null
Loaded APR format ... rc=0

$ apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline </dev/null
Loaded APR format in 0.47s (991.9 MB)
error: Invalid APR format: No Qwen tokenizer found. ...
rc=4

The third still exits non-zero, but for an honest reason: the file is opened and
read, and that fixture has no sidecar tokenizer.json. Two adjacent behaviours are
also checked, because a resolver fix can easily overshoot: a nonexistent absolute
path still reports File not found: /home/noah/models/does-not-exist.apr (rc=3)
rather than a 404, and --offline on an uncached repo now prints
OFFLINE MODE: Model hf://... not cached (rc=5) with no request made.

Falsifiers in crates/apr-cli/src/commands/chat_local_path.rs assert behaviour,
not shape: an absolute path resolves to itself, a slash-bearing relative path
resolves to itself, and --offline on an uncached repo fails with OFFLINE MODE
without contacting huggingface.co.

Mutation check — restoring the pre-fix chat body (HEAD chat.rs:113-126) with the
tests kept turns all three RED:

panicked at chat_local_path.rs:28: an existing absolute path must resolve
  without touching the network: ValidationFailed("Download failed:
  https://huggingface.co//tmp/resolve/main/model.safetensors: status code 404")
panicked at chat_local_path.rs:59: an existing relative path must resolve
  without touching the network: ValidationFailed("Download failed:
  https://huggingface.co/apr-chat-rel-595528-ThreadId(20)/models/resolve/main/tiny.gguf: status code 401")
panicked at chat_local_path.rs:78: offline chat must refuse locally, got:
  Validation failed: Download failed:
  https://huggingface.co/apr-offline-falsifier-org/apr-offline-falsifier-repo/resolve/main/model.safetensors: status code 401
test result: FAILED. 16 passed; 3 failed

Reverting only the run.rs half (keeping the chat delegation) leaves the relative
path RED on its own, so both edits are load-bearing.

cargo test -p apr-cli --lib: 6625 passed, 0 failed.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

🤖 Generated with Claude Code


Fixes #2387

Audit epic: #2373

…slash became hf://<path>

`apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline` on the
crates.io 0.63.0 binary never looked at the file. It rewrote the absolute path
into a HuggingFace repo id and went to the network, which `--offline` did not
stop either:

    $ apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline
    Downloading hf:///home...
      Downloading model.safetensors...
    error: Validation failed: Download failed: https://huggingface.co//home/resolve/main/model.safetensors: status code 404
    rc=5

The same three fixtures (0.5B .apr, 1.5B q4k .apr, 0.8B .gguf) all produced
`Downloading hf:///home...`, and `apr run` opened all of them. Local models were
simply unreachable from `apr chat`.

Root cause: chat carried its own copy of the model-source resolution instead of
using run's. crates/apr-cli/src/commands/chat.rs:116 read

    let hf_uri = if !resolved_source.contains("://") && resolved_source.contains('/') {
        format!("hf://{resolved_source}")

with none of the local-path guards `run::resolve_model_source` has
(`!Path::new(..).exists()`, `!starts_with('/')`), so any argument containing a
slash became a repo id. chat.rs:126 then called
`resolve_model(&model_source, false, false)` with `offline` hard-coded to
`false`, which is why `--offline` could not stop the request.

chat now calls `run::resolve_model_source` and threads the real `--offline`
through to `resolve_model`, so a local path is a local path in both commands and
`--offline` refuses uncached repos locally.

The second half is in run.rs. `resolve_model_source` decided correctly that an
existing relative path like `models/tiny.gguf` was local, then handed it to
`pull::resolve_hf_model`, whose `normalize_hf_uri` re-prepends `hf://` to any
slash-bearing scheme-less argument — undoing the decision. That call is now made
only for arguments that are already `hf://` references. `apr run` had the same
defect for relative paths and is fixed by the same line.

After, with a release binary built from this branch:

    $ apr chat /home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf --offline </dev/null
    === Model Chat (GGUF Format) ===
      Model: /home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf
    Loaded GGUF format in 0.27s (532.5 MB)
    Loaded tokenizer with 248320 tokens
    rc=0

    $ apr chat /home/noah/models/qwen2.5-coder-1.5b-instruct-q4k.apr --offline </dev/null
    Loaded APR format ... rc=0

    $ apr chat /home/noah/models/qwen2.5-coder-0.5b-instruct.apr --offline </dev/null
    Loaded APR format in 0.47s (991.9 MB)
    error: Invalid APR format: No Qwen tokenizer found. ...
    rc=4

The third still exits non-zero, but for an honest reason: the file is opened and
read, and that fixture has no sidecar tokenizer.json. Two adjacent behaviours are
also checked, because a resolver fix can easily overshoot: a nonexistent absolute
path still reports `File not found: /home/noah/models/does-not-exist.apr` (rc=3)
rather than a 404, and `--offline` on an uncached repo now prints
`OFFLINE MODE: Model hf://... not cached` (rc=5) with no request made.

Falsifiers in crates/apr-cli/src/commands/chat_local_path.rs assert behaviour,
not shape: an absolute path resolves to itself, a slash-bearing relative path
resolves to itself, and `--offline` on an uncached repo fails with OFFLINE MODE
without contacting huggingface.co.

Mutation check — restoring the pre-fix chat body (HEAD chat.rs:113-126) with the
tests kept turns all three RED:

    panicked at chat_local_path.rs:28: an existing absolute path must resolve
      without touching the network: ValidationFailed("Download failed:
      https://huggingface.co//tmp/resolve/main/model.safetensors: status code 404")
    panicked at chat_local_path.rs:59: an existing relative path must resolve
      without touching the network: ValidationFailed("Download failed:
      https://huggingface.co/apr-chat-rel-595528-ThreadId(20)/models/resolve/main/tiny.gguf: status code 401")
    panicked at chat_local_path.rs:78: offline chat must refuse locally, got:
      Validation failed: Download failed:
      https://huggingface.co/apr-offline-falsifier-org/apr-offline-falsifier-repo/resolve/main/model.safetensors: status code 401
    test result: FAILED. 16 passed; 3 failed

Reverting only the run.rs half (keeping the chat delegation) leaves the relative
path RED on its own, so both edits are load-bearing.

cargo test -p apr-cli --lib: 6625 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift
noahgift enabled auto-merge August 10, 2026 07:10
@noahgift
noahgift marked this pull request as draft August 10, 2026 08:36
auto-merge was automatically disabled August 10, 2026 08:36

Pull request was converted to draft

@noahgift
noahgift marked this pull request as ready for review August 10, 2026 12:53
@noahgift
noahgift enabled auto-merge August 10, 2026 12:53
Conflict in crates/apr-cli/src/commands/chat.rs. Both sides rewrote the same
model-resolution block, and this one needed reading rather than picking a side.

  main (#2416) added a FileNotFound fast-fail for absolute / ./ / ../ paths
    that do not exist, then kept the old resolution: alias lookup followed by
    `format!("hf://{resolved_source}")` for anything containing a slash, and
    threaded `offline` into ModelSource::parse.

  this branch (#2387) replaced the whole block with `resolve_chat_model`.

Took this branch's resolver, because it strictly supersedes main's block rather
than merely conflicting with it: `resolve_chat_model` carries main's
FileNotFound fast-fail verbatim, threads `offline` through both
`resolve_model_source` and `resolve_model`, and drops the hf:// rewrite that is
the defect this branch exists for. Keeping main's version would have restored
`apr chat /path/to/model.apr` resolving to `hf:///path`.

Confirmed no local from main's block is referenced after the conflict region -
`fully_resolved_source`, `hf_uri`, `model_source`, `source_str` and
`looks_like_path` are all consumed inside it.

250 apr-cli chat unit tests pass; build and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift

Copy link
Copy Markdown
Contributor Author

Parking to let the merge queue drain — branch untouched, this will be reopened.

Six PRs are in the merge queue and their merge_group check runs have been starved for hours: 16 runners, and every open PR keeps re-triggering its own workspace-test alongside them. Cancelling those runs does not hold (new ones replace them within a minute) and drafting does not stop CI on this repo, so closing is the only lever that frees the fleet.

The queue is the only path by which anything actually merges, so it gets the runners until it is empty. Reopening immediately afterwards.

@noahgift

Copy link
Copy Markdown
Contributor Author

Superseded by #2449 — this branch is merged verbatim into that batch.

The binding constraint was one ~50-minute workspace-test per PR on one shared box; nine concurrent PRs starved each other into 75-minute step timeouts (every blocked PR this morning classified as CONTENTION, zero merges in 4.5 hours). #2449 lands 24 branches in a single CI run.

Closing rather than leaving open so this PR cannot move #2449's base and force it to re-run. The branch is untouched and this is reopenable if the batch does not land.

@noahgift noahgift closed this Aug 11, 2026
auto-merge was automatically disabled August 11, 2026 13:38

Pull request was closed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apr chat <local path> rewrites every path with a slash to hf:// and fetches it from HuggingFace

1 participant