Skip to content

fix: use per-provider env key for custom providers in Models dialog#737

Open
365diascollaboration-prog wants to merge 5 commits into
fathah:mainfrom
365diascollaboration-prog:fix/custom-provider-per-key
Open

fix: use per-provider env key for custom providers in Models dialog#737
365diascollaboration-prog wants to merge 5 commits into
fathah:mainfrom
365diascollaboration-prog:fix/custom-provider-per-key

Conversation

@365diascollaboration-prog

Copy link
Copy Markdown
Contributor

Problem

When adding multiple custom providers with unrecognised base URLs, they all resolve to the shared CUSTOM_API_KEY env var via expectedEnvKeyForUrl(). The second provider's key silently overwrites the first, making it impossible to run two custom providers simultaneously. Closes #681.

Root cause

expectedEnvKeyForUrl() returns CUSTOM_API_KEY as a fallback for any URL not in URL_KEY_MAP. The save path in handleSave() and the read-back path in openEditModal() both use this function, so all unknown-URL providers share one bucket.

Note: seedDefaults() in models.ts already writes CUSTOM_PROVIDER_<NAME>_KEY correctly for providers seeded from config.yaml. This fix brings the GUI dialog into alignment with that existing behavior.

Fix

Introduce customProviderEnvKey(name, baseUrl) — a thin wrapper over expectedEnvKeyForUrl that falls back to CUSTOM_PROVIDER_<NAME>_KEY (same naming as seedDefaults) instead of the shared CUSTOM_API_KEY when the URL is unknown.

Three call-site changes, all in Models.tsx:

  • handleSave() — writes the per-provider key
  • openEditModal() — reads the per-provider key, falls back to CUSTOM_API_KEY for keys written by older versions

Testing

  1. Add two custom providers with different unrecognised base URLs and distinct API keys
  2. Verify each model persists its own key in .env (CUSTOM_PROVIDER_<NAME>_KEY)
  3. Re-open each model's edit dialog and confirm the correct key is shown

Custom providers with unrecognised base URLs all resolved to the shared
CUSTOM_API_KEY env var via expectedEnvKeyForUrl(). Adding a second
custom provider with a different URL would overwrite the first provider's
key, making it impossible to run two custom providers simultaneously.

Introduce customProviderEnvKey() that falls back to
CUSTOM_PROVIDER_<NAME>_KEY (matching the naming already used by
seedDefaults() in models.ts) when the URL is not in URL_KEY_MAP.

Apply the same key derivation in openEditModal() so the field reads back
the correct value, with a fallback to CUSTOM_API_KEY for keys written by
older versions.

Fixes fathah#681
@fathah

fathah commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Thanks for the fix. The direction makes sense, but I think this currently only fixes the Models dialog storage/display side, not the full runtime path.

For known custom-compatible hosts, this should still work because we already resolve URL-specific env vars like GROQ_API_KEY, DEEPSEEK_API_KEY, etc.

The issue is unknown custom base URLs. This PR saves the key as:

CUSTOM_PROVIDER_<DISPLAY_NAME>_KEY

But the active chat/gateway path does not consistently read that key. In the normal active config path, unknown custom URLs still rely on model.api_key, CUSTOM_API_KEY, OPENAI_API_KEY, or host-derived keys. So a user can add a custom model, enter an API key, see it saved in the dialog, but chat may still run without that key and fail upstream.

Can we either:

  1. make the runtime/readiness/config-health path read the same CUSTOM_PROVIDER_<DISPLAY_NAME>_KEY, or
  2. keep writing a fallback key the runtime already reads, such as CUSTOM_API_KEY, or
  3. write/update model.key_env / model.api_key when that custom model becomes active?

Without one of those, this may regress unknown custom providers even though the dialog looks correct.

…E>_KEY

config-health was not aware of per-provider env keys written by the
Models dialog and seedDefaults(), so it could report MODEL_KEY_MISSING
even when the gateway runtime would resolve the key correctly.

Mirror the same baseUrl->name lookup that hermes.ts already uses so the
health check and the runtime stay in sync.
@365diascollaboration-prog

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, fathah.

You're right that the original PR only covered the dialog side. I've pushed a second commit that extends customEndpointKeyResolvable() in config.ts to also resolve CUSTOM_PROVIDER__KEY:

  • Looks up the model by �aseUrl from models.json (the same lookup hermes.ts already does in the gateway path)
  • Adds the per-provider key to the candidates set before both the env check and the vault check

This way the config-health path and the runtime gateway path stay in sync — no false MODEL_KEY_MISSING warning for a provider whose key was saved under CUSTOM_PROVIDER__KEY.

The gateway runtime at hermes.ts:startGateway already had the CUSTOM_PROVIDER__KEY fallback (lines 2370–2383), so chat itself should resolve the key correctly once it's written. The gap was only in the health-check side, which this commit closes.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a collision bug where multiple custom providers with unrecognized base URLs all wrote their API keys to the shared CUSTOM_API_KEY env var. It introduces customProviderEnvKey() to derive per-provider CUSTOM_PROVIDER_<NAME>_KEY env vars for unknown URLs, aligning the GUI dialog with the existing seedDefaults() behavior in models.ts.

  • Models.tsx: openEditModal now reads the per-provider key (with a CUSTOM_API_KEY fallback for legacy entries), and handleSave/handleDelete clean up the old key on rename or deletion, guarded by envKeyUsedByOtherModel to avoid wiping shared vendor keys like GROQ_API_KEY still used by sibling entries.
  • config.ts / register.ts / ssh-remote.ts: Adds deleteEnvValue/sshDeleteEnvValue and a new delete-env IPC channel to remove orphaned keys, and extends customEndpointKeyResolvable to scan all models sharing a base URL (via filter instead of find) so the config-health audit correctly resolves per-provider keys.

Confidence Score: 5/5

Safe to merge — the fix is well-scoped and all three guard conditions (per-provider key derivation, shared-key protection, and rename/delete cleanup) have corresponding tests.

The core key-derivation logic is consistent across all three call sites (handleSave, openEditModal, customEndpointKeyResolvable). The envKeyUsedByOtherModel guard correctly prevents wiping shared vendor keys. The new deleteEnvValue/sshDeleteEnvValue functions mirror the existing set counterparts exactly. Test coverage covers the distinct collision scenarios. No regressions identified in the changed paths.

No files require special attention.

Important Files Changed

Filename Overview
src/renderer/src/screens/Models/Models.tsx Core fix: adds customProviderEnvKey(), envKeyUsedByOtherModel(), per-provider key read/write in openEditModal/handleSave, and cleanup on rename/delete. Logic is correct; shared-key guard prevents accidental deletion of vendor keys.
src/main/config.ts Adds deleteEnvValue() (mirrors setEnvValue structure) and extends customEndpointKeyResolvable to scan all models matching baseUrl via call-time require to avoid import cycle.
src/main/ipc/register.ts Registers delete-env IPC handler mirroring set-env pattern with SSH fallback; straightforward plumbing.
src/main/ssh-remote.ts Adds sshDeleteEnvValue() following the same pattern as sshSetEnvValue; regex escape is identical to the existing local implementation.
src/renderer/src/screens/Models/Models.test.tsx New envKeyUsedByOtherModel tests cover the three key scenarios: unique keys, shared vendor keys (Groq), and same-name collision. Good coverage.
tests/delete-env-value.test.ts New tests for deleteEnvValue covering key removal, no-op on missing key, and no-op when file doesn't exist. Uses proper tmp-dir isolation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Models.tsx (Renderer)
    participant IPC as IPC (register.ts)
    participant Config as config.ts
    participant SSH as ssh-remote.ts
    participant ENV as .env file

    Note over UI: openEditModal(model)
    UI->>IPC: getEnv()
    IPC->>Config: readEnv()
    Config->>ENV: read
    ENV-->>Config: env record
    Config-->>IPC: env record
    IPC-->>UI: env record
    UI->>UI: env[customProviderEnvKey(name,url)] ?? env[CUSTOM_API_KEY]

    Note over UI: handleSave()
    UI->>IPC: setEnv(newKey, apiKey)
    IPC->>Config: setEnvValue(newKey, apiKey)
    Config->>ENV: write newKey
    UI->>UI: envKeyUsedByOtherModel(oldKey, id, models)?
    alt oldKey unused by siblings
        UI->>IPC: deleteEnv(oldKey)
        IPC->>Config: deleteEnvValue(oldKey)
        Config->>ENV: remove oldKey line
    end

    Note over UI: handleDelete(id)
    UI->>IPC: removeModel(id)
    UI->>UI: envKeyUsedByOtherModel(envKey, id, models)?
    alt envKey unused by siblings
        UI->>IPC: deleteEnv(envKey)
        alt local mode
            IPC->>Config: deleteEnvValue(envKey)
            Config->>ENV: remove envKey line
        else SSH mode
            IPC->>SSH: sshDeleteEnvValue(envKey)
            SSH->>ENV: remove envKey line (remote)
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Models.tsx (Renderer)
    participant IPC as IPC (register.ts)
    participant Config as config.ts
    participant SSH as ssh-remote.ts
    participant ENV as .env file

    Note over UI: openEditModal(model)
    UI->>IPC: getEnv()
    IPC->>Config: readEnv()
    Config->>ENV: read
    ENV-->>Config: env record
    Config-->>IPC: env record
    IPC-->>UI: env record
    UI->>UI: env[customProviderEnvKey(name,url)] ?? env[CUSTOM_API_KEY]

    Note over UI: handleSave()
    UI->>IPC: setEnv(newKey, apiKey)
    IPC->>Config: setEnvValue(newKey, apiKey)
    Config->>ENV: write newKey
    UI->>UI: envKeyUsedByOtherModel(oldKey, id, models)?
    alt oldKey unused by siblings
        UI->>IPC: deleteEnv(oldKey)
        IPC->>Config: deleteEnvValue(oldKey)
        Config->>ENV: remove oldKey line
    end

    Note over UI: handleDelete(id)
    UI->>IPC: removeModel(id)
    UI->>UI: envKeyUsedByOtherModel(envKey, id, models)?
    alt envKey unused by siblings
        UI->>IPC: deleteEnv(envKey)
        alt local mode
            IPC->>Config: deleteEnvValue(envKey)
            Config->>ENV: remove envKey line
        else SSH mode
            IPC->>SSH: sshDeleteEnvValue(envKey)
            SSH->>ENV: remove envKey line (remote)
        end
    end
Loading

Reviews (3): Last reviewed commit: "fix: don't delete a shared vendor key st..." | Re-trigger Greptile

Comment thread src/renderer/src/screens/Models/Models.tsx
Comment thread src/main/config.ts
- Fix import ordering in Models.tsx: customProviderEnvKey() was
  declared between two import blocks.
- Clean up orphaned CUSTOM_PROVIDER_<NAME>_KEY env entries when a
  custom provider is renamed or deleted (new deleteEnvValue/
  sshDeleteEnvValue + delete-env IPC handler + preload deleteEnv).
- customEndpointKeyResolvable now checks every model sharing a base
  URL instead of only the first find() match, so a second custom
  provider on the same unknown host no longer gets a false
  "key missing" warning.
@365diascollaboration-prog

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit addressing all three review points:

  1. Import ordering — moved import type { ModelRegistry, ... } above customProviderEnvKey(), so all imports are grouped at the top of the file as suggested.
  2. Orphaned key on rename/delete — added deleteEnvValue() (local) / sshDeleteEnvValue() (remote) plus a delete-env IPC handler and hermesAPI.deleteEnv, mirroring the existing setEnv/set-env path. handleSave now deletes the old CUSTOM_PROVIDER_<OLDNAME>_KEY when a rename changes the derived key, and handleDelete deletes it when the provider is removed.
  3. Single-match find() in the health checkcustomEndpointKeyResolvable now uses filter() over all models sharing a base URL instead of only the first match, so two differently-named custom providers on the same unknown host both resolve correctly.

Added tests/delete-env-value.test.ts covering the new deleteEnvValue() behavior. Existing suites (Models.test.tsx, config-health.test.ts) still pass, and tsc --noEmit / eslint are clean on all touched files.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

T-Rex pricing update — T-Rex was free through June 2026. Effective July 1, 2026, T-Rex adds 2 credits on top of the standard 1-credit review (3 total). T-Rex settings

Comment thread src/renderer/src/screens/Models/Models.tsx Outdated
envKeyUsedByOtherModel() checks the in-memory models list before
handleSave/handleDelete call deleteEnv, so renaming or removing one
custom provider no longer wipes a key another entry still resolves
to (e.g. two custom entries on the same known vendor host sharing
GROQ_API_KEY, or two unknown-host entries with the same name).
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.

Custom providers share a single CUSTOM_API_KEY — per-provider API keys are ignored at runtime

2 participants