Skip to content

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

Open
365diascollaboration-prog wants to merge 3 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 3 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 key-collision bug where every custom provider with an unrecognised base URL shared the single CUSTOM_API_KEY env var, causing each new provider to silently overwrite the previous one's key. The fix introduces customProviderEnvKey() in Models.tsx and extends customEndpointKeyResolvable() in config.ts to align with the per-provider naming already used by seedDefaults().

  • Models.tsx: a new helper generates CUSTOM_PROVIDER_<NAME>_KEY for unknown URLs; openEditModal reads it with a backward-compat fallback to CUSTOM_API_KEY, and handleSave writes to it.
  • config.ts: the health-check function now performs a call-time require(\"./models\") to look up the model name from the base URL and include the per-provider key in its candidate set, so the health audit no longer reports false "key missing" warnings for per-named keys.

Confidence Score: 4/5

The core fix is sound and correctly addresses the key-collision problem for new providers; existing providers get a safe read-back fallback.

The fix correctly isolates per-provider env keys and aligns the GUI dialog with the existing seedDefaults() naming convention. Three minor concerns were found: customProviderEnvKey is inserted between two import blocks rather than before them; renaming a provider during edit leaves the old CUSTOM_PROVIDER__KEY orphaned in the env file with no cleanup; and the health-check find() in config.ts only picks up the first model matching a given base URL, so two differently-named providers sharing the same unknown URL would still trigger a false health warning for the second one. None of these affect the primary scenario described in the PR.

Both changed files warrant a second look: Models.tsx for the import ordering and the missing old-key cleanup on rename, and config.ts for the single-match limitation in the health-check lookup.

Important Files Changed

Filename Overview
src/renderer/src/screens/Models/Models.tsx Introduces customProviderEnvKey() to generate per-provider env keys for unknown-URL custom providers; updates openEditModal and handleSave to use it. The function is placed between two import blocks (style issue), and handleSave does not clean up the old key when a provider is renamed.
src/main/config.ts Extends customEndpointKeyResolvable to look up the per-provider key via a call-time require('./models'), so the config-health check recognises CUSTOM_PROVIDER__KEY entries. The find() call only adds the first matching model's key when multiple providers share the same base URL.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant Models.tsx
    participant url-key-map
    participant hermesAPI
    participant config.ts
    participant models.ts

    User->>Models.tsx: Open edit modal for custom provider
    Models.tsx->>url-key-map: expectedEnvKeyForUrl(baseUrl)
    alt URL is in URL_KEY_MAP (known provider)
        url-key-map-->>Models.tsx: e.g. GROQ_API_KEY
    else Unknown URL (after this PR)
        url-key-map-->>Models.tsx: CUSTOM_API_KEY
        Models.tsx->>Models.tsx: customProviderEnvKey() → CUSTOM_PROVIDER_NAME_KEY
    end
    Models.tsx->>hermesAPI: getEnv()
    hermesAPI-->>Models.tsx: env vars map
    Models.tsx->>Models.tsx: env[CUSTOM_PROVIDER_NAME_KEY] ?? env[CUSTOM_API_KEY]
    Models.tsx-->>User: Pre-fills API key field

    User->>Models.tsx: Save provider with API key
    Models.tsx->>Models.tsx: customProviderEnvKey(formName, formBaseUrl)
    Models.tsx->>hermesAPI: setEnv(CUSTOM_PROVIDER_NAME_KEY, apiKey)

    Note over config.ts,models.ts: Health-check path
    config.ts->>models.ts: readModels() via require()
    models.ts-->>config.ts: model list
    config.ts->>config.ts: "find(m => m.baseUrl === baseUrl)"
    config.ts->>config.ts: add CUSTOM_PROVIDER_NAME_KEY to candidates
    config.ts->>config.ts: check env for any candidate key
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 User
    participant Models.tsx
    participant url-key-map
    participant hermesAPI
    participant config.ts
    participant models.ts

    User->>Models.tsx: Open edit modal for custom provider
    Models.tsx->>url-key-map: expectedEnvKeyForUrl(baseUrl)
    alt URL is in URL_KEY_MAP (known provider)
        url-key-map-->>Models.tsx: e.g. GROQ_API_KEY
    else Unknown URL (after this PR)
        url-key-map-->>Models.tsx: CUSTOM_API_KEY
        Models.tsx->>Models.tsx: customProviderEnvKey() → CUSTOM_PROVIDER_NAME_KEY
    end
    Models.tsx->>hermesAPI: getEnv()
    hermesAPI-->>Models.tsx: env vars map
    Models.tsx->>Models.tsx: env[CUSTOM_PROVIDER_NAME_KEY] ?? env[CUSTOM_API_KEY]
    Models.tsx-->>User: Pre-fills API key field

    User->>Models.tsx: Save provider with API key
    Models.tsx->>Models.tsx: customProviderEnvKey(formName, formBaseUrl)
    Models.tsx->>hermesAPI: setEnv(CUSTOM_PROVIDER_NAME_KEY, apiKey)

    Note over config.ts,models.ts: Health-check path
    config.ts->>models.ts: readModels() via require()
    models.ts-->>config.ts: model list
    config.ts->>config.ts: "find(m => m.baseUrl === baseUrl)"
    config.ts->>config.ts: add CUSTOM_PROVIDER_NAME_KEY to candidates
    config.ts->>config.ts: check env for any candidate key
Loading

Comments Outside Diff (1)

  1. src/renderer/src/screens/Models/Models.tsx, line 38-51 (link)

    P2 Function declared between import blocks

    customProviderEnvKey is inserted between the value-import block (lines 31-34) and the import type block (lines 47-51), leaving a bare function declaration sandwiched between two import groups. While TypeScript hoists imports before function declarations at runtime, most ESLint import/order or no-code-between-imports configurations will flag this. The import type block should be moved above the function so all imports are grouped at the top of the file.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "fix: extend customEndpointKeyResolvable ..." | Re-trigger Greptile

Comment on lines 400 to 405
}

if (formApiKey.trim() && formProvider === "custom") {
const envKey = expectedEnvKeyForUrl(formBaseUrl.trim());
const envKey = customProviderEnvKey(formName.trim(), formBaseUrl.trim());
await window.hermesAPI.setEnv(envKey, formApiKey.trim());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Orphaned env key when provider name is changed during edit

When a user edits an existing custom provider and renames it (e.g. "Foo" → "Bar"), handleSave writes the API key to CUSTOM_PROVIDER_BAR_KEY while CUSTOM_PROVIDER_FOO_KEY is left behind with a stale value. The old key is never deleted. Since the save path has access to both editingModel?.name (old name) and formName (new name), it could issue a setEnv(oldKey, "") call when the name changes during an edit.

Comment thread src/main/config.ts
Comment on lines +781 to +793
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- call-time require; models.ts has no dep on config.ts so no cycle.
const modelsMod = require("./models") as typeof import("./models");
const matching = modelsMod.readModels().find((m) => m.baseUrl === baseUrl);
if (matching) {
candidates.add(
"CUSTOM_PROVIDER_" +
matching.name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase() +
"_KEY",
);
}
} catch {
/* ignore */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 readModels().find() returns only the first match when two providers share a base URL

customEndpointKeyResolvable resolves the candidate key by matching the baseUrl argument against the model list. If two custom providers share the same base URL (both unrecognised by URL_KEY_MAP), find returns only the first and only that provider's CUSTOM_PROVIDER_<NAME>_KEY is added to candidates. The health check would falsely report the second provider's key as missing even though the key exists under its own 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