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;