Skip to content

Claude blocks: configurable endpoint (ANTHROPIC_BASE_URL) and timeout - #26

Merged
XNinety9 merged 3 commits into
XNinety9:masterfrom
salazr:anthropic-base-url
Oct 26, 2026
Merged

XNinety9 merged 3 commits into
XNinety9:masterfrom
salazr:anthropic-base-url

Conversation

@salazr

@salazr salazr commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Why

The Claude blocks are the only part of Printr that must call a specific third-party endpoint — everything else is local or uses free, keyless APIs ("frugal by default" is the project's own line). With the URL hardcoded to api.anthropic.com:

  • Privacy: a family ticket regularly carries children's names, photos, the household agenda, and even guest Wi-Fi credentials (QR block). Pointing the endpoint at a local inference server (oMLX, Ollama, VLLM, LM Studio) keeps that content on the home network.
  • Cost and availability: no API key, no per-call billing, and the Claude blocks keep working without internet.
  • Internal consistency: the CLI path (Claude Code) already honors ANTHROPIC_BASE_URL — the standard convention of the Claude Code ecosystem. The API path was the one of the two that couldn't be steered; one variable keeps one convention in the same program.
  • Slow local models: the hardcoded 120 s timeout is right for the Anthropic API, but a quantized local model writing a recipe or a bedtime story can exceed it.

Printr's audience — Pi, systemd, MQTT/Node-RED, Docker, self-hosting — is exactly the crowd already running local LLMs; one environment variable lets Printr join that setup.

What changes

  • ANTHROPIC_BASE_URL points the API path at any Messages-API-compatible endpoint. /v1/messages is appended when absent (trailing slash tolerated); the key is still sent as x-api-key, no client change.
  • PRINTR_CLAUDE_TIMEOUT_SECS replaces the hardcoded 120 s timeout (API and CLI paths); default unchanged.
  • Unit tests for URL/timeout resolution.
  • Docs: guide (en + fr), CONTRIBUTING (en + fr), CHANGELOG.

Verified

Against a local MLX inference server exposing the Messages API: the exact request Printr sends (system prompt + output_config with effort and JSON schema) is accepted, and a horoscope returns schema-matching JSON in seconds. cargo fmt --check passes with no diff and the aarch64 build produces no warnings.

Happy to rename the variable or split the timeout into a separate PR if preferred.

The Messages API URL was hardcoded to api.anthropic.com, so the Claude
blocks could not use any compatible local or self-hosted endpoint
(oMLX, Ollama, vLLM, LM Studio, ...).

- ANTHROPIC_BASE_URL (same convention as the Claude Code CLI) points the
  API path at another Messages API endpoint; /v1/messages is appended
  when absent, the key is still sent as x-api-key
- PRINTR_CLAUDE_TIMEOUT_SECS replaces the hardcoded 120 s call timeout
  (both API and CLI paths); slow local models need more
- unit tests for URL/timeout resolution
- docs: guide (en/fr), CONTRIBUTING (en/fr), CHANGELOG
@salazr

salazr commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Validation against a local inference server (oMLX, Messages API on 127.0.0.1:8000):

  • binary built from this branch (aarch64, Raspberry Pi 4, no warnings)
  • ANTHROPIC_BASE_URL pointed at the local server, PRINTR_CLAUDE_MODEL=Qwen3.6-35B-A3B-oQ8e-mtp
  • ticket printed through the emupos TM-T88V emulator — horoscope and word of the day written by the local model
  • cargo test: 169 passed, 0 failed (includes the new URL/timeout tests)

The ticket, rendered dot-for-dot by the emulator (512 dots, 180 dpi):

Ticket rendered by the TM-T88V emulator, Claude blocks generated by the local model

Terminal capture (PRINTR_DEBUG=1, raw response from the endpoint — note the model field):

  ✓ title · The Morning Ticket
  ✓ date
  ✓ word of the day             cache
  [claude] {"content":[{"text":"{\n  \"date\": \"2026-10-23\",\n  \"sign\": \"Leo\",\n  \"general\": \"The sky has decided to rain upside down today…",…}],
            "id":"msg_44c2323ed7434fa08107d07c","model":"Qwen3.6-35B-A3B-oQ8e-mtp","stop_reason":"end_turn",…}
  ✓ horoscope · leo (absurd)    12.6 s
✓ Printed to tcp://127.0.0.1:9100  │ 6 blocks · ≈ 25 cm of paper · 12.6 s

(No physical printer was used: --tcp pointed at the emulator. Full terminal log: evidence/pr26-terminal.txt on the fork.)

One honest note on model quality: with this local model, structured output is occasionally malformed — 2 of 5 horoscope attempts returned prose (or a json prefix) instead of the schema. The affected block printed its error and the rest of the ticket printed, as designed; a retry on parse failure (currently only empty fields are retried) would smooth this over for weaker models.

@XNinety9 XNinety9 added this to the 1.11.1 Lanston milestone Oct 25, 2026

@XNinety9 XNinety9 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this, it's a great fit for Printr: keeping family content on the home network is exactly the spirit of the project, and the PR is small, clear and tested. I'd like to ship it in 1.11.1, after three small changes:

1. ANTHROPIC_BASE_URL is ignored when there is no ANTHROPIC_API_KEY. Claude::from_env only picks the API backend when a key is set; otherwise it falls back to the Claude CLI. Many local servers (Ollama, LM Studio…) don't need a key, so someone who only sets ANTHROPIC_BASE_URL would silently get the CLI (or "Claude unavailable") without understanding why. Could you pick the API backend whenever ANTHROPIC_BASE_URL is set, and send x-api-key only when a key exists? If you prefer to keep it simple, a line in the docs saying "set ANTHROPIC_API_KEY to any value for a server that needs no key" would do too.

2. PRINTR_CLAUDE_TIMEOUT_SECS=0 gives a zero timeout, so every call fails right away. Ignoring 0 (or any value below a few seconds) and keeping the default would avoid that, e.g. .filter(|&s| s > 0).

3. The tests change the process environment (set_var/remove_var) while other tests run in parallel. That can make the suite flaky (we already had to fix a test like that). Could you split the parsing into pure functions that take the value as a parameter, e.g. fn api_url_from(value: Option<&str>) -> String and fn timeout_from(value: Option<&str>) -> Duration, test those, and have api_url() / api_timeout() read the environment and call them? The PC850 code page patch (#31) does it that way in cp858.rs.

One sentence I'd add to the docs: with ANTHROPIC_BASE_URL, the API key is sent to that server, so it should only point to a server you trust (or use a dummy key for a local one).

Small detail, no need to change anything: api_timeout() is read on every loop of the CLI wait (every 20 ms); reading it once before the loop would be enough.

Thanks again, looking forward to it!

salazr pushed a commit to salazr/Printr that referenced this pull request Oct 26, 2026
…rd, pure parse functions

- Pick the API backend whenever ANTHROPIC_BASE_URL is set; send x-api-key only when a key exists (local servers need no key)
- PRINTR_CLAUDE_TIMEOUT_SECS=0 (or garbage) now falls back to the 120 s default instead of a zero timeout
- Split URL/timeout parsing into pure functions (api_url_from / timeout_from) and test those, so no test touches the process environment anymore
- Read the CLI timeout once before the wait loop
- Docs: trust note for ANTHROPIC_BASE_URL, keyless note (en + fr)

Requested in review of PR XNinety9#26 by XNinety9.
@salazr
salazr force-pushed the anthropic-base-url branch from 48be53b to 3efaa0e Compare October 26, 2026 04:03
salazr added a commit to salazr/Printr that referenced this pull request Oct 26, 2026
…rd, pure parse functions

- Pick the API backend whenever ANTHROPIC_BASE_URL is set; send x-api-key only when a key exists (local servers need no key)
- PRINTR_CLAUDE_TIMEOUT_SECS=0 (or garbage) now falls back to the 120 s default instead of a zero timeout
- Split URL/timeout parsing into pure functions (api_url_from / timeout_from) and test those, so no test touches the process environment anymore
- Read the CLI timeout once before the wait loop
- Docs: trust note for ANTHROPIC_BASE_URL, keyless note (en + fr)

Requested in review of PR XNinety9#26 by XNinety9.

(cherry picked from commit 3efaa0e)
@salazr
salazr force-pushed the anthropic-base-url branch from 3efaa0e to 3185e84 Compare October 26, 2026 04:10
…nctions

- Pick the API backend whenever ANTHROPIC_BASE_URL is set; send x-api-key only when a key exists (local servers need no key)
- PRINTR_CLAUDE_TIMEOUT_SECS=0 (or garbage) now falls back to the 120 s default instead of a zero timeout
- Split URL/timeout parsing into pure functions (api_url_from / timeout_from) and test those, so no test touches the process environment anymore
- Read the CLI timeout once before the wait loop
- Docs: trust note for ANTHROPIC_BASE_URL, keyless note (en + fr)

Requested in review of PR XNinety9#26 by XNinety9.

(cherry picked from commit 3efaa0e)
@salazr
salazr force-pushed the anthropic-base-url branch from 3185e84 to 1cd81cf Compare October 26, 2026 04:13
@salazr

salazr commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Hey, certainly! The changes are committed, with details below.

One point I missed initially: thank you for making this. My children are enjoying it.


  1. ANTHROPIC_BASE_URL without a key. Claude::from_env now picks the API backend whenever ANTHROPIC_BASE_URL is set, and sends x-api-key only when ANTHROPIC_API_KEY is present. Verified against a small local Messages-API stub that records request headers:
REQUEST /v1/messages model=mock-printr-pr26 headers={… no x-api-key …}        → x-api-key=ABSENT   (no key set)
REQUEST /v1/messages model=mock-printr-pr26 headers={…"x-api-key":"…" …}     → x-api-key=PRESENT  (key set)

Docs (en + fr) now say the key is not needed for a compatible server.

  1. PRINTR_CLAUDE_TIMEOUT_SECS=0. 0 (and non-numeric values) now fall back to the 120 s default instead of disabling the timeout. Every e2e run below ran with PRINTR_CLAUDE_TIMEOUT_SECS=0 — zero failures.

  2. Pure resolution, env-free tests. api_url()/timeout() are thin wrappers over pure api_url_from(Option<&str>) / timeout_from(Option<&str>) (the cp858.rs pattern); the unit tests no longer touch env.

Terminal capture from the keyless run (new binary, ANTHROPIC_API_KEY unset, PRINTR_CLAUDE_TIMEOUT_SECS=0):

✓ title · The Morning Ticket
  ✓ date
  ✓ separator
  ✓ separator
[claude] {"content":[{"text":"{\"general\":\"Today, a spoon-shaped cloud will follow you…"}],"id":"msg_mock_8sy2ll47","model":"mock-messages-api (pr26 keyless test)","stop_reason":"end_turn",…}
[claude] {"content":[{"text":"{\"mot\":\"petrichor\",…"}],"id":"msg_mock_7wg42zgh",…}
  ✓ horoscope · leo (absurd)
  ✓ word of the day
✓ Printed to tcp://127.0.0.1:9100  │ 6 blocks · ≈ 23 cm of paper · 126 ms

The ticket:

pr26-keyless-ticket

cargo fmt --check clean · cargo test: 170 passed, 0 failed (includes the new URL/timeout tests).

@XNinety9 XNinety9 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot! All three points are addressed, and the keyless run against a local server is exactly what I hoped for. fmt, clippy and the tests pass locally. Merging: it will ship in 2.0 'Mergenthaler'. Glad your kids enjoy it

@XNinety9
XNinety9 merged commit a9bf0da into XNinety9:master Oct 26, 2026
6 checks passed
XNinety9 added a commit that referenced this pull request Oct 26, 2026
XNinety9 added a commit that referenced this pull request Oct 26, 2026
PRINTR_CLAUDE_TIMEOUT_SECS (from #26) can be set in the app. ANTHROPIC_BASE_URL stays out on purpose: the Claude key is sent to that address, and the app never hands the key out.
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.

2 participants