From 08444dff0c306efee8a4a8526ae65ff49caa68f3 Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Sun, 12 Jul 2026 18:06:33 +0200 Subject: [PATCH] feat: remember an API key per provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single shared key meant switching preset carried the previous provider's credential over. Clearing it didn't help either — the next provider inherited whatever was left in the field, so an OpenAI `sk-…` went out as an Anthropic `x-api-key` and 401'd. Keys now live in a per-provider map. Selecting a provider shows its own key (blank until set), and coming back restores the one already typed. Clearing drops that provider's entry only. Editing the Base URL still flips to Custom, which carries the current key across unless Custom is already keyed. Storage stays sessionStorage-only. The old shared `wingman:apiKey` slot is migrated onto the provider that was active, then dropped. --- README.md | 2 +- src/App.tsx | 82 ++++++++++++++++++++++++++++++++----- src/components/Composer.tsx | 12 +++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9b6ce9a..137fe2b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Open , then: - **Provider preset** — pick a provider (OpenAI, Anthropic, Mistral, Groq, …) to auto-fill its base URL and wire format. The default, **Custom / Manifest**, pre-fills the Manifest Cloud gateway (`https://app.manifest.build`) — edit the Base URL by hand to point at your own gateway (which switches the pill back to Custom). - **Format** — the wire protocol: OpenAI Chat Completions (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`), or Anthropic Messages (`/v1/messages`). This sets the endpoint path, auth scheme, body shape, and how the response is parsed. - **Base URL** — e.g. `https://your-manifest.example.com`, `https://api.openai.com`, `https://api.anthropic.com`, or `http://localhost:3001` (more on cross-origin below). Wingman appends the format's path. -- **API key** — `Authorization: Bearer` for OpenAI-style formats, `x-api-key` for Anthropic (attached automatically per format). +- **API key** — `Authorization: Bearer` for OpenAI-style formats, `x-api-key` for Anthropic (attached automatically per format). Keys are kept per provider: switching preset shows that provider's own key (blank until you set one), and switching back restores it. - **Model** — `auto` or a specific model id. - **Stream** — toggle in the composer toolbar to read the reply as it's generated (Server-Sent Events). diff --git a/src/App.tsx b/src/App.tsx index 1c1f904..9393596 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import GistModal from './components/GistModal.jsx'; const STORAGE = { baseUrl: 'wingman:baseUrl', - apiKey: 'wingman:apiKey', + apiKeys: 'wingman:apiKeys', model: 'wingman:model', profile: 'wingman:profile', format: 'wingman:format', @@ -49,11 +49,15 @@ const STORAGE = { provider: 'wingman:provider', }; +// The pre-per-provider slot: a single key shared by every provider. Read once +// on boot and folded into the map (see `readApiKeys`), then dropped. +const LEGACY_API_KEY = 'wingman:apiKey'; + // API keys are stored in sessionStorage (cleared on tab close) instead of // localStorage so contributors don't leave a long-lived `mnfst_*` token in // disk-backed browser storage. Everything else (base URL, model, profile, // system prompts, history) stays in localStorage since it's not sensitive. -const SENSITIVE_KEYS = new Set(['wingman:apiKey']); +const SENSITIVE_KEYS = new Set([STORAGE.apiKeys, LEGACY_API_KEY]); function storageFor(key: string): Storage { return SENSITIVE_KEYS.has(key) ? sessionStorage : localStorage; @@ -85,6 +89,14 @@ function writeStorage(key: string, value: string): void { } } +function removeStorage(key: string): void { + try { + storageFor(key).removeItem(key); + } catch { + /* ignore */ + } +} + function entriesFromRecord(record: Record): HeaderEntry[] { return Object.entries(record).map(([key, value]) => ({ key, value })); } @@ -137,6 +149,43 @@ function providerForBaseUrl(url: string): string { return match ? match.id : DEFAULT_PROVIDER_ID; } +/** API keys keyed by provider id. Only non-empty keys are kept. */ +type ApiKeyMap = Record; + +function parseApiKeyMap(raw: string): ApiKeyMap { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: ApiKeyMap = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (typeof value === 'string' && value) out[id] = value; + } + return out; + } catch { + return {}; + } +} + +// Keys are held per provider rather than globally: each provider issues its own +// credential, so a single shared key meant switching preset silently carried an +// `sk-…` into an Anthropic `x-api-key` header (a guaranteed 401) with no way to +// get the previous key back short of re-pasting it. +function readApiKeys(currentProviderId: string): ApiKeyMap { + const map = parseApiKeyMap(readStorage(STORAGE.apiKeys, '')); + // A session that predates the map still has the old shared key. Attribute it + // to the provider that was selected when it was typed and drop the old slot. + const legacy = readStorage(LEGACY_API_KEY, ''); + if (legacy) { + removeStorage(LEGACY_API_KEY); + if (!map[currentProviderId]) { + map[currentProviderId] = legacy; + writeStorage(STORAGE.apiKeys, JSON.stringify(map)); + } + } + return map; +} + function resolveInitialProfile(formatId: ApiFormatId): string { const compatible = profilesForFormat(formatId); const stored = readStorage(STORAGE.profile, ''); @@ -157,19 +206,23 @@ const App: Component = () => { (initialProvider.id !== DEFAULT_PROVIDER_ID ? initialProvider.baseUrl : readStorage(STORAGE.baseUrl, defaultBaseUrl())); - const initialApiKey = apiKeyParam ?? readStorage(STORAGE.apiKey, ''); + const initialApiKeys = readApiKeys(initialProviderId); + if (apiKeyParam) initialApiKeys[initialProviderId] = apiKeyParam; if (baseUrlParam) writeStorage(STORAGE.baseUrl, baseUrlParam); - if (apiKeyParam) writeStorage(STORAGE.apiKey, apiKeyParam); + if (apiKeyParam) writeStorage(STORAGE.apiKeys, JSON.stringify(initialApiKeys)); const initialFormatId = resolveInitialFormat(initialProviderId); const [providerId, setProviderId] = createSignal(initialProviderId); const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); - const [apiKey, setApiKey] = createSignal(initialApiKey); + const [apiKeys, setApiKeys] = createSignal(initialApiKeys); const [model, setModel] = createSignal(readStorage(STORAGE.model, 'auto')); const [formatId, setFormatId] = createSignal(initialFormatId); const [profileId, setProfileId] = createSignal(resolveInitialProfile(initialFormatId)); const [stream, setStream] = createSignal(readStorage(STORAGE.stream, '0') === '1'); const provider = createMemo(() => PROVIDER_BY_ID[providerId()] ?? CUSTOM_PROVIDER); + // The key field shows whatever belongs to the active provider — so switching + // preset blanks it (until that provider is keyed) and switching back fills it. + const apiKey = createMemo(() => apiKeys()[providerId()] ?? ''); const format = createMemo(() => FORMAT_BY_ID[formatId()] ?? FORMATS[0]!); const availableProfiles = createMemo(() => profilesForFormat(formatId())); const profile = createMemo( @@ -303,10 +356,14 @@ const App: Component = () => { setBaseUrl(value); writeStorage(STORAGE.baseUrl, value); }; - const persistAndSetKey = (value: string) => { - setApiKey(value); - writeStorage(STORAGE.apiKey, value); + const setKeyFor = (id: string, value: string) => { + const next = { ...apiKeys() }; + if (value) next[id] = value; + else delete next[id]; + setApiKeys(next); + writeStorage(STORAGE.apiKeys, JSON.stringify(next)); }; + const persistAndSetKey = (value: string) => setKeyFor(providerId(), value); const persistAndSetModel = (value: string) => { setModel(value); writeStorage(STORAGE.model, value); @@ -319,7 +376,8 @@ const App: Component = () => { // Pick a provider preset: switch to its wire format and, for a concrete // provider, fill the base URL + a usable default model. Selecting "Custom / // Manifest" resets to the Manifest gateway defaults (base URL pre-filled, the - // `auto` router model) — the field stays free-text for a BYO endpoint. + // `auto` router model) — the field stays free-text for a BYO endpoint. The + // API key needs no handling here: it's derived from the active provider. const selectProvider = (id: string) => { const preset = PROVIDER_BY_ID[id]; if (!preset || id === providerId()) return; @@ -336,12 +394,16 @@ const App: Component = () => { }; // Typing in the Base URL field means the user has gone off-preset — flip to - // Custom (keeping model/format) so the field stays fully free-text. + // Custom (keeping model/format) so the field stays fully free-text. Retargeting + // the URL isn't a deliberate provider switch, so the key already typed follows + // the user across (unless Custom is already keyed — never clobber that one). const handleBaseUrlInput = (value: string) => { persistAndSetBase(value); if (providerId() !== DEFAULT_PROVIDER_ID) { + const carried = apiKey(); setProviderId(DEFAULT_PROVIDER_ID); writeStorage(STORAGE.provider, DEFAULT_PROVIDER_ID); + if (carried && !apiKeys()[DEFAULT_PROVIDER_ID]) setKeyFor(DEFAULT_PROVIDER_ID, carried); } }; diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 983f951..a0df6cf 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, Show, type Component } from 'solid-js'; +import { createEffect, createSignal, For, on, Show, type Component } from 'solid-js'; import HeaderEditor, { type HeaderEntry } from './HeaderEditor.jsx'; import CodeView from './CodeView.jsx'; import ProfileDropdown from './ProfileDropdown.jsx'; @@ -207,6 +207,16 @@ const Composer: Component = (props) => { const [connOpen, setConnOpen] = createSignal(true); const [sdkOpen, setSdkOpen] = createSignal(false); + // The key field holds a different secret per provider, so leaving it revealed + // across a switch would show the next provider's key unasked. Re-mask it. + createEffect( + on( + () => props.activeProviderId, + () => setApiKeyRevealed(false), + { defer: true }, + ), + ); + const headersCount = () => props.headers.filter((h) => h.key.trim()).length; const sysLen = () => props.systemPrompt.trim().length; const blockedCount = () => props.blockedHeaders.length;