Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Open <https://wingman.manifest.build>, 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).

Expand Down
82 changes: 72 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,23 @@ 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',
stream: 'wingman:stream',
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<string>(['wingman:apiKey']);
const SENSITIVE_KEYS = new Set<string>([STORAGE.apiKeys, LEGACY_API_KEY]);

function storageFor(key: string): Storage {
return SENSITIVE_KEYS.has(key) ? sessionStorage : localStorage;
Expand Down Expand Up @@ -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<string, string>): HeaderEntry[] {
return Object.entries(record).map(([key, value]) => ({ key, value }));
}
Expand Down Expand Up @@ -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<string, string>;

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<string, unknown>)) {
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, '');
Expand All @@ -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<ApiKeyMap>(initialApiKeys);
const [model, setModel] = createSignal(readStorage(STORAGE.model, 'auto'));
const [formatId, setFormatId] = createSignal<ApiFormatId>(initialFormatId);
const [profileId, setProfileId] = createSignal(resolveInitialProfile(initialFormatId));
const [stream, setStream] = createSignal(readStorage(STORAGE.stream, '0') === '1');

const provider = createMemo<Provider>(() => 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<ApiFormat>(() => FORMAT_BY_ID[formatId()] ?? FORMATS[0]!);
const availableProfiles = createMemo<Profile[]>(() => profilesForFormat(formatId()));
const profile = createMemo<Profile>(
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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);
}
};

Expand Down
12 changes: 11 additions & 1 deletion src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -207,6 +207,16 @@ const Composer: Component<Props> = (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;
Expand Down
Loading