fix: Unsloth Studio support — adapter, onboarding auth-question skip, and broken auth.json credential store - #33
Conversation
…-compatible servers Pre-existing WIP carried over from before this branch: the generic adapter previously ignored context_window/max_model_len/context_length/ max_context_length and max_completion_tokens fields entirely, always falling back to hardcoded defaults (8192/4096) even when the server reported real values.
Unsloth Studio always requires an API key (no unauthenticated mode at all), so it needs its own adapter rather than falling through to the generic OpenAI-compatible fallback. - fingerprint() identifies the backend from the `Server: unsloth-studio` response header, present on every response (401 with no/invalid key, and 200 authenticated alike) — verified against a live instance via curl. Deliberately does NOT claim a bare 200 + data[] on its own (indistinguishable from any other OpenAI-compatible server); falls through to the generic adapter in that case, per the existing fingerprint-discrimination-must-be-honest rule. - Adds IntrospectLoaded support from the real `loaded: boolean` field each /v1/models entry reports. BREAKING (contract): adds an optional `authRequired?: boolean` field to `BackendAdapter` (src/core/backend-adapter.ts) and a new "unsloth" BackendKind (src/core/capability.ts). Both files are the FROZEN CONTRACT per ARCHITECTURE.md — this bumps CONTRACT_VERSION 2 -> 3. The change itself is additive/non-breaking for existing adapters, but the frozen files were touched and need explicit review. Fixture/tests use response data captured verbatim from a live Unsloth Studio instance, not synthesized examples.
… backends (Problem 2 of Hypabolic#31) The manual-add flow asked the auth question before ever probing the server, so a user could pick "No authentication" for a backend that can never work without a key (e.g. Unsloth Studio) and land on the generic "could not identify the server" dead end once every adapter's fingerprint 401s. Now pre-probes unauthenticated, once, before asking. If a matching adapter declares `authRequired: true`, the auth-choice select is skipped entirely and the user goes straight to the API-key prompt, with an explicit notify naming which backend needs it. Falls through to the original behaviour unchanged when the pre-probe fails or matches nothing, or when no adapter declares authRequired.
…blem 3 of Hypabolic#31) `createPiCredentialStore()` built the CredentialStore from `ctx.modelRegistry.authStorage`, which pi-coding-agent >=0.80.8 removed from the extension-facing SDK surface entirely (see that release's CHANGELOG: "AuthStorage and its storage backends are no longer exported"). Since Crossbar's package.json pins the devDependency to 0.79.9 but the globally-installed `pi` binary users actually run can be (and was, in the field) much newer, this crashed with "Cannot read properties of undefined (reading 'set')" the moment a server with `auth: apiKey` was added. Replaces it with auth-json-credential-store.ts, which reads/writes auth.json directly in Pi's own flat shape (atomic writes, 0600, read-modify-write so it never touches another provider's entry) — restoring the "secrets live only in auth.json" guarantee without depending on the removed SDK internals. Its header doc also explains why built-in providers (anthropic, openai, github-copilot, ...) were never affected: they run through Pi's internal ModelRuntime, which kept privileged access all along; only the EXTENSION provider API lost its bridge.
…sentinel Found while manually testing the Hypabolic#31 fixes end-to-end against a real Unsloth Studio instance: adding a keyed server succeeded (key persisted via the new auth-json-credential-store), but `pi.setModel()` still silently returned false ("Pi could not select <model>"). registerServer() registers the provider with apiKey: "$ENVVAR" (never the plaintext key, by design), relying on Pi's own config-value resolver to fill it in from process.env at request time — but nothing ever set that env var. The original assumption (encoded in a now- corrected comment) was that Pi resolved auth.json by provider id on its own for ANY registered model; empirically true for pi-coding-agent 0.79.9 (verified via the real AuthStorage/ModelRegistry in provider-shim.test.ts) but not reliably observable for extension- registered providers under newer Pi versions, whether due to the extension-facing API changes in >=0.80.8 or an in-memory credential snapshot that never sees our out-of-band auth.json write. registerServer() and preloadCachedProviders() now set process.env[envVarFor(record.id)] to the resolved key immediately before pi.registerProvider(...), so Pi's live process.env read finds it regardless. Added an end-to-end CLI integration test that spawns the real local `pi` binary against an isolated agent dir with a throwaway dummy key and confirms the model is listed as available via --list-models — the exact chain that was broken. Also cleans up test pollution: registerServer's process.env mutation is real and process-global, so provider-shim.test.ts now resets it in afterEach to keep the (deliberately isolated) real-ModelRegistry "keyed availability" tests hermetic.
…never described
Unsloth Studio emits `context_length` / `max_context_length` /
`native_context_length` ONLY for the model that is currently loaded. Verified
against a live instance:
{"id":"Qwen3.6-35B-A3B-UD-Q4_K_M","context_length":262144,...,"loaded":true}
{"id":"Qwen3.8-27B-Q8_0","loaded":false} <- no context fields at all
The adapter answered that silence with an invented `contextWindow: 8192` /
`maxTokens: 4096`. Those numbers were not merely displayed as "8k ctx" in the
model picker for every unloaded model — including ones with a real 262144-token
context — they were also frozen into `lastKnownModels` in crossbar.json, so the
lie survived restarts.
Adopt the contract the llama.cpp and llama-swap adapters already follow after
review: report only what the backend actually said. `contextWindowFor` now
returns `undefined` instead of a default, `listModels` omits the field entirely
when unknown (so it picks up the real value as soon as the model is loaded), and
the single fallback lives at the Pi-mapping boundary in `toPiModel`, where the
field is mandatory — 128_000 context and `maxTokens: 0` ("no client-side cap,
let the server decide"), identical to the sibling adapters.
Caches already on disk are repaired: `sanitizeLegacyModelCache` now covers
`unsloth` alongside `llamaswap`/`llamacpp`, stripping exactly the fabricated
8192/4096 pair while leaving authoritative values untouched. Since existing
configs are already stamped `modelCacheVersion: 1` and would otherwise skip
migration, the marker is bumped to 2 so the one-time re-sanitize actually runs.
Tests: regression coverage for the unloaded/loaded/max/native cases and both
toPiModel branches, plus an `unsloth` record in the persistence migration
fixture. The conformance fixture declares `maxTokensMayBeUnbounded`.
Detect Unsloth Studio's "Switch model by request" setting via GET /api/settings/openai-auto-switch (verified against a live instance) and surface it in Crossbar's model pickers: - Contract v4: new optional autoLoadsOnDemand() + Capability.AutoLoadStatus (non-breaking; only backends that can answer authoritatively implement it) - Unsloth adapter implements it defensively: non-200 / missing endpoint / malformed body all yield undefined (unknown) — old server versions keep today's unmarked behaviour - Model pickers (Use a model in Pi, switch/load actions): not-loaded models get a ○ prefix + "no auto-load" badge when the server answered false, plus a footnote pointing at the backend's own load UI; selecting one also warns that the server cannot serve it until loaded - Conformance harness gains an autoLoadStatus section; docs updated (capability matrix incl. the missing Unsloth row, ARCHITECTURE version)
Selecting a model the server cannot serve only fails on the first request — so the picker now blocks it instead of warning afterwards: - selectOverlay gains an optional disabled set: Enter on a disabled item is swallowed and the reason is shown as a warning notification, the overlay stays open (Esc still cancels) - performUseModel / performModelAction / add-server flow all pass the disabled set; performUseModel skips the picker entirely when no model is loaded at all (dead end) and explains why - the old post-selection warning is gone (unreachable) - resolveAutoLoadsOnDemand now takes (server, cred) so the add-server flow can reuse it before the record exists - new exported helpers: manualLoadDisabledValues, manualLoadFootnote, manualLoadReason (selectOverlay exported for tests) - tests: 6 new behavioral tests drive the REAL pi-tui SelectList headlessly (raw key sequences) verifying Enter is swallowed on ○ items, navigation onto them still works, and back-compat holds
|
Found (and fixed) another two problems: |
There was a problem hiding this comment.
Pull request overview
This PR improves Crossbar’s compatibility with Unsloth Studio and newer pi-coding-agent runtimes by adding a dedicated adapter, fixing onboarding/auth handling, and replacing the broken credential persistence path with a direct auth.json store (plus an env-var bridge for Pi’s $ENV sentinel). It also enhances the generic OpenAI-compatible adapter to respect server-reported context window / token limits, and adds extensive unit, conformance, and integration coverage.
Changes:
- Add an Unsloth Studio backend adapter (fingerprinting via
Server: unsloth-studio,authRequired: true, loaded-state introspection, and auto-load status support). - Update onboarding/model-picking UX to pre-probe unauthenticated once, skip the “No authentication” choice for auth-required backends, and mark/disable models that cannot be served until loaded.
- Replace the removed Pi
authStoragewrite path with a directauth.jsoncredential store, and bridge resolved keys intoprocess.envfor$ENV-based provider configs (including preload-time).
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/ui/onboarding.test.ts | Adds/selects overlay-disabled-item behavior tests; expands model-picker helper coverage. |
| tests/ui/onboarding-flow.test.ts | Verifies authRequired backends skip the auth-choice prompt during manual add. |
| tests/shim/provider-shim.test.ts | Adds assertions around the process.env bridge; ensures env cleanup between tests. |
| tests/registry/persistence.test.ts | Updates expectations for model-cache version bump and legacy cache sanitization. |
| tests/registry/auth-json-credential-store.test.ts | New unit tests for direct auth.json credential store behavior (permissions, atomicity expectations, preservation). |
| tests/preload.test.ts | Ensures preload bridges persisted auth.json keys into process.env when present. |
| tests/integration/cli-preload.test.ts | New end-to-end regression test confirming keyed providers become available via real pi --list-models. |
| tests/conformance/run-conformance.ts | Extends conformance harness to validate autoLoadsOnDemand when capability is present. |
| tests/conformance/fixtures.ts | Adds fixture support for autoLoadStatus expectations. |
| tests/adapters/unsloth.test.ts | New adapter-specific tests covering authRequired and fingerprint discrimination + AutoLoadStatus behavior. |
| tests/adapters/unsloth.fixture.ts | New conformance fixture captured from a live Unsloth Studio instance. |
| src/ui/onboarding.ts | Adds manual-load UI semantics (○ marking, disabled selection handling), selectOverlay enhancements, and authRequired pre-probe logic. |
| src/shim/provider-shim.ts | Bridges resolved API keys into process.env[envVarFor(id)] for $ENV resolution at request time. |
| src/registry/registry.ts | Bumps persisted modelCacheVersion written by the registry. |
| src/registry/pi-credential-store.ts | Removes the legacy AuthStorage-based credential store bridge. |
| src/registry/persistence.ts | Bumps model cache version and expands legacy sanitization for fabricated context/maxTokens values. |
| src/registry/auth-json-credential-store.ts | New direct auth.json-backed credential store implementation. |
| src/preload.ts | Preload now reads persisted keys from auth.json and bridges them into process.env before registration. |
| src/index.ts | Switches runtime credential store creation to the new auth.json store. |
| src/core/types.ts | Updates config schema to allow modelCacheVersion 1 or 2 with rationale. |
| src/core/index.ts | Exposes canAutoLoadStatus helper from the core module index. |
| src/core/capability.ts | Adds AutoLoadStatus capability and new backend kind "unsloth". |
| src/core/backend-adapter.ts | Bumps contract version and adds authRequired? + autoLoadsOnDemand? to adapter contract. |
| src/adapters/unsloth.ts | New Unsloth Studio adapter implementation. |
| src/adapters/index.ts | Registers/exports the new Unsloth adapter in the adapter set. |
| src/adapters/generic.ts | Enhances generic /v1/models parsing to read context window and max token limits when available. |
| package-lock.json | Lockfile updates from dependency resolution changes. |
| CHANGES.md | New changelog entry documenting generic adapter context/maxTokens improvements. |
| CAPABILITY-MATRIX.md | Updates capability matrix to include AutoLoadStatus and Unsloth Studio row. |
| ARCHITECTURE.md | Updates documented CONTRACT_VERSION to match the revised adapter contract. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /** | ||
| * Render a single-select overlay (titled SelectList in an accent border) and resolve | ||
| * to the chosen item value, or `null` on Esc/cancel. Shared by the model picker and | ||
| * the manage menus so they stay visually consistent. | ||
| /** | ||
| * Items the user may navigate to but NOT confirm (Enter is swallowed and | ||
| * `reason` is shown as a notification). Used for models the server cannot serve |
| * Writes are read-modify-write + atomic rename (temp file on the same | ||
| * filesystem), so a concurrent Pi-side write (e.g. an OAuth login finishing | ||
| * around the same time) can only ever lose one side's *own* key entry in the | ||
| * unlikely event both writes race — never corrupt the file. Every entry | ||
| * belonging to other providers (anthropic, openrouter, github-copilot, ...) |
Closes #31.
Summary
Three separate problems reported when adding an Unsloth Studio server (see #31 for full repro/root-cause writeups), plus one more found while manually verifying the fix end-to-end against a live instance.
1. New Unsloth Studio adapter (
src/adapters/unsloth.ts)Unsloth Studio has no unauthenticated mode at all — every request, including
GET /v1/models, requires a bearer key.fingerprint()identifies it from theServer: unsloth-studioresponse header, present on every response (401 with no/invalid key, and 200 authenticated alike) — verified against a live instance via curl, not guessed. Deliberately does not claim a bare200 + data[]on its own (indistinguishable from any other OpenAI-compatible server) — falls through to the generic adapter in that case. AddsIntrospectLoadedsupport from the realloaded: booleanfield each model entry reports.2. Onboarding dialog now skips "No authentication" for
authRequiredbackends (src/ui/onboarding.ts)The manual-add flow asked the auth question before ever probing the server, so a user could pick "No authentication" for a backend that can never work without a key and land on a generic "could not identify the server" dead end. Now pre-probes unauthenticated once before asking; if a matching adapter declares
authRequired: true, the choice is skipped entirely and the user goes straight to the API-key prompt.3. Broken credential store on pi-coding-agent >=0.80.8 (
src/registry/auth-json-credential-store.ts)createPiCredentialStore()readctx.modelRegistry.authStorage, which pi-coding-agent >=0.80.8 removed from the extension-facing SDK entirely ("AuthStorage and its storage backends are no longer exported", per that release's CHANGELOG). Crossbar'spackage.jsonpins the devDependency to0.79.9, but the globally-installedpibinary users actually run can be much newer — this crashed withCannot read properties of undefined (reading 'set')the moment a keyed server was added. Replaced with a module that reads/writesauth.jsondirectly, in Pi's own flat shape (atomic writes, 0600, read-modify-write so it never touches another provider's entry).4.
process.envbridge for the$ENVsentinel (found during live testing)Even with the key correctly persisted,
pi.setModel()still silently returnedfalse.registerServer()registers providers withapiKey: "$ENVVAR"(never the plaintext key), relying on Pi's own config-value resolver to fill it in fromprocess.env— but nothing ever set that variable.registerServer()andpreloadCachedProviders()now bridge the resolved key intoprocess.env[envVarFor(record.id)]immediately beforepi.registerProvider(...).Testing
tsc --noEmit: clean.vitest run: 668 passed, 8 skipped, 0 failed.tests/integration/cli-preload.test.ts) spawns the real localpibinary against an isolated agent dir with a throwaway dummy key and confirms the model is listed as available via--list-models— the exact chain that was broken (Problem 4).unsloth.fixture.tsuses response data captured verbatim from a live Unsloth Studio instance, not synthesized examples.Commits
f206cc7fix(generic): read context window/maxTokens fields reported by OpenAI-compatible servers (pre-existing WIP carried along)c4a7c3bfeat(adapters): add Unsloth Studio adapter (Problem 1)1db85ebfix(onboarding): skip the "No authentication" choice for authRequired backends (Problem 2)88bb28afix(registry): replace broken authStorage-based credential store (Problem 3)4b0d8b3fix(shim): bridge the resolved API key into process.env for the $ENV sentinel (Problem 4)