Skip to content

feat(coach): local / self-hosted LLM provider with an optional API key - #51

Merged
foureight84 merged 5 commits into
mainfrom
feat/local-llm-coach
Aug 22, 2026
Merged

feat(coach): local / self-hosted LLM provider with an optional API key#51
foureight84 merged 5 commits into
mainfrom
feat/local-llm-coach

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Adds a LOCAL_OPENAI_COMPAT coach provider pointing at any OpenAI-Chat-Completions-compatible server the user runs themselves — Ollama, llama.cpp (llama-server), vLLM, SGLang, LM Studio. The API key is optional.

Research and full rationale: docs/local-llm-coach.md.

Why Chat Completions, not Responses

Every engine in scope converged on POST {base}/v1/chat/completions + GET {base}/v1/models. None implements the Responses API this app speaks natively in a usable/universal form, so this is a translating adapter — structurally the same as the existing MiniMaxClient / OpenRouterClient.

Engine Default Auth tools response_format
Ollama :11434 ignored ("required but ignored") yes yes; no tool_choice, no image URLs
llama.cpp :8080 none unless --api-key yes, best with --jinja json_object + json_schema
vLLM :8000 none unless --api-key only with --enable-auto-tool-choice yes (xgrammar)
SGLang :30000 none unless --api-key yes yes, + regex/ebnf
LM Studio :1234 none yes json_schema only

The four things a local backend does that a hosted one doesn't

  1. Optional API key. All of these run unauthenticated by default, so requiring a key would leave the coach permanently disabled for the normal setup. The readiness sentinel that gates CoachFeatureFlags.coachEnabled becomes the base URL instead of a key; a blank key omits the Authorization header entirely.

  2. role: developer is unsafe. SGLang validates roles against a pydantic Literal with a validator that raises (→ HTTP 400) outside it — developer was added to that list only recently, and released versions in the wild reject it. vLLM accepts the role (extra="allow") and hands it straight to the model's Jinja chat template, which usually has no branch for it. So the adapter folds developersystem unconditionally. Related: many local templates require the system turn to be first and singular, so all system turns are merged into one leading message (MiniMaxClient appends the schema instruction as a trailing system message, which would break on those).

  3. Capabilities are user-declared, not assumed. Tool calling and structured output are switches. Defaults are the combination that works everywhere: tools on, response_format off + prompt-injected schema (with the orchestrator's JSON-repair loop as the backstop). json_schema / json_object are opt-in. No reasoning, cache_control, or provider-routing block is ever sent.

  4. Cleartext + slow inference. The app shipped no network_security_config.xml, so every LAN request would have failed with CleartextNotPermitted. Added one — NSC has no CIDR syntax, so the "private hosts only" restriction is enforced in LocalEndpoint.validate (loopback / RFC1918 / CGNAT / link-local / *.local; public hosts must be https://). ResponsesHttp's hardcoded 60 s read timeout gains a per-call override (local default 180 s, configurable).

Also in here

  • GET /v1/models discovery behind a "Test connection & load models" button in Settings.
  • Tool-call arguments accepted as a JSON string or an object — several local tool-call parsers emit the latter.
  • Local turns price at $0 regardless of model name, so a slug like qwen3:8b can't prefix-match into a cloud rate.
  • Drive-by fix: the Settings hub trailing label showed the OpenAI model slug for MiniMax.
  • versionCode 37 → 38.

Testing

46 unit tests across LocalEndpoint, LocalOpenAICompatClient, LocalModelCatalog and the resolver. assembleDebug green.

Not yet hardware-tested — no local server available in the dev environment. Worth a smoke test against Ollama and one vLLM/SGLang box before merging.

Adds a LOCAL_OPENAI_COMPAT coach provider pointing at any OpenAI-Chat-Completions-compatible
server the user runs themselves — Ollama, llama.cpp (llama-server), vLLM, SGLang, LM Studio.
Research and the full rationale are in docs/local-llm-coach.md.

Key differences from the hosted providers, each forced by real local behavior:

- The API key is OPTIONAL. All of these run unauthenticated by default, so requiring a key
  would leave the coach permanently off for the normal setup. The readiness sentinel that
  gates CoachFeatureFlags.coachEnabled becomes the base URL instead.
- `developer` is folded to `system` and every system turn is merged into one leading message.
  SGLang validates roles against a pydantic Literal and raises (HTTP 400) outside it; vLLM
  accepts the role but hands it to a chat template with no branch for it. Many local templates
  also require the system turn to be first and singular.
- Tool calling and structured output are user-declared switches, not assumptions: vLLM 400s on
  `tools` without --enable-auto-tool-choice, and LM Studio has no json_object mode. Defaults are
  the combination that works everywhere (tools on, response_format off + prompt-injected schema).
- Cleartext HTTP is enabled via network_security_config.xml, with LocalEndpoint.validate
  enforcing the private/loopback-only rule that Network Security Config has no CIDR syntax for.
- Read timeout is configurable (default 180 s); ResponsesHttp gains a per-call override and a
  GET for /v1/models model discovery.

Local turns are priced at $0 regardless of the model name, so a slug like `qwen3:8b` can't
prefix-match into a cloud rate. Also fixes the Settings hub trailing label, which showed the
OpenAI model slug for MiniMax.

46 unit tests across LocalEndpoint, LocalOpenAICompatClient, LocalModelCatalog and the resolver.
…ities

One press of "Detect server & configure" now sets up the local provider from just a base URL.

The two settings most likely to fail a turn — `tools` and `response_format` — are gated by the
server's LAUNCH FLAGS, which no metadata endpoint exposes (`/v1/models` describes the model, not
the request surface). So LocalCapabilityProbe sends the fields and reads the answer:

1. GET /v1/models — reachability, model list, sole-model shortcut. Only fatal step.
2. Engine identity, best-effort, from each engine's own info route: /version (vLLM),
   /api/version (Ollama), /props (llama.cpp), /get_server_info (SGLang), /api/v0/models
   (LM Studio). Deliberately not `owned_by`, which every proxy rewrites. Cosmetic only.
3. A chat request carrying one throwaway tool.
4. A chat request carrying a minimal json_schema; json_object only if that is refused.

4xx = the server refused the field (vLLM answers 400 for a disabled tool parser and 422 for an
unknown field, so the status carries no extra meaning). 5xx or transport failure = inconclusive,
and the setting is LEFT AT ITS DEFAULT rather than switched off, with a note saying why. Tool
calling only ever turns off on an explicit refusal — an inconclusive probe must not silently
strip the coach of its ability to read the user's data. The probe also never picks a model when
several are served and none matches the current one; guessing would move a working setup.

Corrects docs/local-llm-coach.md §2 on vLLM. Verified against a live vLLM 0.27.1 server:
`role: developer` returns HTTP 422 "unknown role: developer". The earlier claim that vLLM
tolerates the role came from v0.11.0's pydantic models (extra="allow"); current vLLM
deserializes strictly. Two of the five engines now hard-fail without the fold, so the
developer -> system fold is load-bearing rather than defensive.

Also: a reasoning model truncated mid-thought (content null, finish_reason "length") now reports
the token limit and points at the Max tokens field instead of a bare "model returned no output".
vLLM 0.27 puts the chain of thought in `message.reasoning`, older builds and SGLang in
`reasoning_content`; the adapter reads neither.
…xt window

Auto-detect now reads the context window each engine advertises and fills in Max tokens from it:
vLLM `max_model_len`, llama.cpp `n_ctx`/`n_ctx_train`, LM Studio `loaded_context_length`/
`max_context_length`, Ollama `model_info["<arch>.context_length"]` via POST /api/show, SGLang
/get_model_info. The summary line now ends with e.g. "262k ctx".

It is a DERIVATION, not a copy. A context window is prompt + completion, and a server checks
`max_tokens` against what is left after the prompt — so copying 262144 across would have the
request rejected outright on any server where the prompt matters. Instead:

  headroom  = context - 6144        (measured coach prompt 3.1-3.3k, doubled for tool results
                                     and replayed history)
  suggested = min(headroom, 32768)  (a coach_response needs far less; the cap stops a 262k
                                     context becoming a runaway generation budget)
  headroom < 512 -> leave blank and warn

The warning is the more valuable half. Ollama's default num_ctx is 2048 — smaller than the coach's
own prompt — so without detection the prompt is silently truncated and the model gets blamed. The
note points at the server-side fix (num_ctx / -c / --max-model-len), not at an app setting, because
no app setting can help there.

13 new tests: the derivation (cap, reserve, prompt+budget always fits, Ollama-2048 flagged rather
than budgeted, unreported context stays blank) and per-engine field parsing.
Detect server & configure is also how you refresh the model list, so it
gets pressed on setups that already work. It was overwriting settings the
user had chosen by hand:

- Tool calling and Response format are now only written when the probe
  reached a verdict. `suggested*`'s safe defaults (tools ON, structured
  OFF) are right for a first run and wrong for a re-detect — a user who
  turned tools off for a vLLM server without --enable-auto-tool-choice
  had them switched back on, and every turn 400'd. Probes are skipped
  entirely when pickModel returns blank on a multi-model server.
- Max tokens is no longer cleared when the server reports no context
  window. 0 means "not detected", not "discard what the user typed".
- The capability probes are now measured against a baseline chat request
  carrying no optional fields. Without it any whole-request rejection —
  an unloadable model id, an auth-gated chat route, a broken chat
  template — read as "tools: not supported" and persisted
  toolCalling = false, costing the coach all data access while blaming
  the wrong thing. A refused baseline skips the probes and changes
  nothing.

Also:

- stripThinking handles a leading unmatched </think>. R1-style distills
  on llama.cpp/Ollama get the opening tag from the chat template, so the
  completion starts mid-thought; the whole chain of thought was reaching
  CoachResponseParser and burning maxFinalAttempts repair generations at
  up to 180 s each before the turn failed.
- Local readiness is validate(baseUrl) == null, not isNotBlank(). The
  field persists per keystroke, so one character flipped the coach to
  "Active" while every turn failed with the URL error already shown
  inline in Settings.
- An unnormalizable URL raises Decoding(MALFORMED), not MissingAPIKey —
  the key is optional on this provider.
- isPrivateHost accepts local-only names: single-label hosts, .lan,
  .home, .internal, .home.arpa, .ts.net. Addressing a box by the name
  the router or mDNS hands out is ordinary, and it was rejected with
  "must be on your local network" — where it was.
- The Timeout field reconciles with the store on focus loss; it clamps to
  10..1800 on write and skips blank, so the text could show a value that
  was never stored, or an empty box over a live setting.
- The local provider sends with redirects disabled. validate() vets the
  typed URL, not where the request lands, and cleartext is permitted
  app-wide — a 307 off the LAN would resend the health-context body in
  the clear. Cloud providers keep redirects.

docs/local-llm-coach.md §3 and §5a updated to match.

1099 tests, 0 failures.
@foureight84
foureight84 merged commit c6167b8 into main Aug 22, 2026
1 check passed
@foureight84
foureight84 deleted the feat/local-llm-coach branch August 22, 2026 17:56
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.

1 participant