Menu

#80 feat(tui): add known-service presets to the provider list

closed
nobody
None
2026-08-10
2026-08-07
Anonymous
No

Originally created by: sosidudku1

Closes [#69].

Problem

Adding anything other than OpenRouter or AI/ML API meant typing a base URL from memory. That is how a user ended up with an OpenAI endpoint paired with a non-OpenAI key: the wizard offered no middle ground between the two curated catalogs and a blank URL field.

Change

  • Ten verified services sit as their own rows in the same flat provider list, matching how other agent CLIs present providers: Nous Research, Groq, DeepSeek, Together AI, Fireworks AI, Cerebras, Mistral, xAI, Ollama Cloud, LM Studio (local). Manual entry stays as the last row. Row order lives in one exported KIND_ROW_ORDER; the render layer derives its labels from it.
  • A preset is not a new provider kind. Picking one resolves to the existing openai-compatible kind with the base URL filled in and goes straight to the key step. Model lists still come from each server's own /v1/models (#31, [#41]), so nothing here needs updating when a vendor ships a new model. Base URLs are stored without the /v1 suffix, following the repo convention (call sites append /v1/...).
  • Each service keeps its own API key. Every preset declares its own env var (GROQ_API_KEY, NOUS_API_KEY, ...), the entry records it as apiKeyEnvVar, and key resolution treats it as authoritative. Adding a second service cannot overwrite the first one's key, and the key screen names the variable that will actually be used. Replacing a key mid-session updates process.env unconditionally, so the running session resolves the new key without a restart.
  • Keyless services save without a key. LM Studio (local) and the keyless-listing endpoints (Nous, Ollama Cloud) accept an empty key: nothing is written to .env, resolution returns undefined, and requests carry no Authorization header at all (an empty Bearer token is malformed).
  • Entries coexist instead of overwriting. Each preset keeps its own entry id (groq, nous, ...), and a second entry for the same service gets a numbered suffix (groq-2) via suggestPresetEntryId, now wired into the entry builder.
  • Reconfigure keeps the entry's identity. The configure wizard recovers the preset behind the entry id (numbered suffixes included) and reuses the existing id on save, so reconfiguring groq updates groq in place instead of minting an openai-compatible duplicate and switching the active provider to it. Hand-added compat entries keep their custom ids on reconfigure too.
  • Every URL was verified live: each answers /v1/models with an OpenAI-shaped payload (200 with a data array, or 401/403 asking for a key, which confirms the path exists).

The provider list is 13 rows now; renderPickList on current main (#67) windows every list to PICK_WINDOW rows with a position counter, so the pick_kind step clips on short terminals instead of overflowing.

Testing

New and updated tests across the preset layer: per-preset env vars (unique, never the shared compat or catalog names, named after the service), base URLs stored without /v1, keyless flags kept on the verified services (presence checks, not a pinned list), suggestPresetEntryId suffixing, presetForEntryId suffix recovery, entry-builder id selection (add, second entry, reconfigure, hand-added id), and save-path scenarios: a Groq key lands in GROQ_API_KEY, two services keep separate keys, a mid-session key replacement updates the live environment, an empty key is refused for Groq, LM Studio saves without a key, a second Groq becomes groq-2, reconfigure keeps groq and the active provider. Providers area plus the wizard component: 68 passing across 7 files. tsc clean. Full suite: 3516 passing, with the same 8 pre-existing failures as current main.

Related

Tickets: #41
Tickets: #69
Tickets: #85

Discussion

  • Anonymous

    Anonymous - 2026-08-07

    Originally posted by: Ooooze

    Reviewed by applying the diff onto current main (010998b), running tsc and the wizard tests, and curling every preset endpoint. The direction is right and the URLs are correct, but four functional issues need fixing before merge — the first one reproduces exactly the failure mode [#69] was filed about (a key paired with the wrong endpoint).

    What checks out

    The diff applies cleanly onto current main with git apply --3way, npm run lint is clean, and src/tui/providers + providers-wizard.test.tsx are 40/40 green. All nine cloud endpoints are live: nous and ollama.com return 200, the rest 401/403, so the path exists and asks for a key. The base URLs work because normalizeOpenAiBaseUrl strips the trailing /v1, turning https://api.groq.com/openai/v1 into https://api.groq.com/openai + /v1/models.

    Blockers

    1. Every preset writes its key into the same OPENAI_COMPAT_API_KEY. The env var name is derived from kind alone, and every preset resolves to openai-compatible:

    // src/tui/persist-llm-provider.ts
    export function dotenvKeyForProviderKind(kind: ProvidersWizardKind) {
      if (kind === "openrouter") return "OPENROUTER_API_KEY";
      if (kind === "aimlapi") return "AIMLAPI_API_KEY";
      return "OPENAI_COMPAT_API_KEY";
    }
    

    The read path is symmetric — resolveLlmProviderApiKey also switches on entry.kind, and load-config.ts:499 injects that key into every entry. Add Groq, then DeepSeek: the Groq key is overwritten, both config entries now resolve the DeepSeek key, and Groq returns 401. The config entries coexist, their keys do not, so the isolation the PR description claims only holds halfway. Separately, writeProviderApiKeyToDotenv only assigns process.env[envKey] when it is currently empty, so inside a running session the runtime keeps using the first key even though .env has already been rewritten. Fix by making the key per-entry — either entry.apiKey in config.json (the schema already supports it) or an env name derived from the entry id.

    2. The LM Studio preset cannot be saved. The comment says a local server needs no key and "the api_key step accepts empty", which is true of the step — but saveProviderWizardToConfig then throws API key is empty — paste a key or set it in .env first whenever OPENAI_COMPAT_API_KEY is absent from the environment. The one preset advertised as "no API key needed" fails on save in the common case.

    3. suggestPresetEntryId is dead code. It is exported and covered by three tests but never called: providerIdForKind returns presetId verbatim, and mergeProviderIntoBlock replaces by id, so a second Groq entry silently overwrites the first. The description's "a second entry for the same service gets a numbered suffix" does not match the code. Either wire it into buildProviderEntryFromWizard or drop it together with its tests — tests on an unused function read as coverage that is not there.

    4. Reconfigure (c) breaks preset entries. createProvidersWizardState("configure", …) never sets presetId, so on save providerIdForKind(kind, null) returns "openai-compatible". Opening a saved groq entry and changing the model creates a new openai-compatible entry, orphans groq, and switches the active text provider to the duplicate. This bug already existed for hand-added JSON entries; presets make it the default path. Minimal fix: providerIdForKind(kind, wizard.presetId ?? wizard.providerId).

    Smaller items

    listsModelsWithoutKey and local are never read outside their own test, even though the JSDoc promises the wizard can list models before a key is entered. Meanwhile expect(keyless).toEqual(["nous", "ollama-cloud"]) will break the moment anyone adds a preset with that flag, while asserting nothing real.

    The comment in provider-presets.test.ts says the provider appends /chat/completions rather than /v1/chat/completions. That is inverted: every call site (openai-provider.ts:86,94,134) appends /v1/..., and the codebase stores base URLs without /v1 (OPENAI_COMPAT_DEFAULT_BASE_URL = "https://api.openai.com"). The presets only work because normalization strips the suffix, and the /\/v1$/ assertion pins the opposite of the repo's convention.

    The provider list grew from 3 to 13 rows, but renderPickList draws every row for pick_kind — the PICK_WINDOW = 12 viewport only applies to the discovered model list. With the border, title and hint that is ~17 lines, which overflows a short terminal. Related: row order now lives in two places, KIND_OPTIONS in the component and KIND_ROW_ORDER in the key bindings, with index alignment held together implicitly. Worth exporting one list.

    Minor: the key step shows OPENAI_COMPAT_API_KEY as the env hint for Groq (a symptom of issue 1), apiKeyForWizard hardcodes id/kind as openai-compatible for its fallback lookup, and dropping as const from KIND_OPTIONS widens id to string — that field is never read, since renderPickList only consumes label.

    Merge state

    GitHub reports mergeable: false / dirty and the PR's base commit (5ea2167) is not in main's history. The diff itself applies cleanly onto current main, so the conflict is mechanical — a rebase should clear it.

     

    Related

    Tickets: #69

  • Anonymous

    Anonymous - 2026-08-07

    Originally posted by: sosidudku1

    Thanks for the thorough pass. Rebased onto current main (d113ebc) and addressed all four blockers plus the smaller items; the branch is now two commits.

    1. Per-preset keys. Each preset declares its own env var (GROQ_API_KEY, NOUS_API_KEY, ...), the entry records it as apiKeyEnvVar, and resolveLlmProviderApiKey treats it as authoritative with no fallback to the shared compat variables, so Groq plus DeepSeek keep two keys and load-config injects the right one per entry. I went with the env-name route rather than entry.apiKey in config.json to keep secrets out of the config file. The key screen now hints the per-service variable, and apiKeyForWizard probes it too instead of the hardcoded compat lookup. Also fixed the related bug you spotted: writeProviderApiKeyToDotenv assigns process.env[envKey] unconditionally now, so a running session resolves a replaced key immediately.

    2. Keyless saves. saveProviderWizardToConfig no longer demands a key when the preset is local or listsModelsWithoutKey, so those flags are read for real now. An empty key is a valid state: nothing lands in .env, resolution returns undefined, and buildOpenAiHeaders omits the Authorization header entirely instead of sending an empty Bearer. Tests cover both sides: LM Studio saves with an empty key, Groq with an empty key still refuses.

    3. suggestPresetEntryId is wired in. The entry builder takes the taken ids, so adding a second Groq lands as groq-2 next to the first instead of replacing it (test included).

    4. Reconfigure. The configure wizard recovers the preset behind the entry id (numbered suffixes included, via the new presetForEntryId) and the save path reuses the existing entry id verbatim, so c on groq updates groq in place: no openai-compatible duplicate, no active-provider switch. That also fixes the pre-existing case you mentioned, since hand-added compat entries keep their custom ids on reconfigure too.

    Smaller items: the /v1 comment was indeed inverted, and I went with the repo convention instead of leaning on normalization: presets store API roots without /v1 and the test now asserts the absence with a comment explaining that call sites append /v1/.... KIND_ROW_ORDER is the single exported list (it moved into providers-wizard-phases.ts during the rebase) and the component derives its labels from it; the widened id field on KIND_OPTIONS is gone entirely, since only labels were ever read, and the narrow typing lives on ProvidersWizardKindRow. The keyless test checks presence rather than pinning the exact list.

    On the pick_kind overflow: the rebase resolves it for free. [#67] moved the PICK_WINDOW viewport into renderPickList for every list, so the 13-row provider list clips to 12 rows with the (1/13) counter instead of overflowing.

    The rebase conflict was mechanical as you predicted. tsc is clean and the full suite shows 3516 passing with the same 8 pre-existing failures as main.

     

    Related

    Tickets: #67

  • Anonymous

    Anonymous - 2026-08-10

    Ticket changed by: Ooooze

    • status: open --> closed
     

Log in to post a comment.