diff --git a/README.md b/README.md index 2acfacc..4bee271 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Your API key is held in `sessionStorage` (cleared when you close the tab). Every Open , then: +- **Provider preset** — pick a provider (OpenAI, Anthropic, Mistral, Groq, …) to auto-fill its base URL and wire format, or stay on **Custom / Manifest** to point at your own gateway. Editing the Base URL by hand switches 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). diff --git a/public/icons/providers/cerebras.svg b/public/icons/providers/cerebras.svg new file mode 100644 index 0000000..a3b612c --- /dev/null +++ b/public/icons/providers/cerebras.svg @@ -0,0 +1 @@ +Cerebras diff --git a/public/icons/providers/deepseek.svg b/public/icons/providers/deepseek.svg new file mode 100644 index 0000000..3fc2302 --- /dev/null +++ b/public/icons/providers/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/public/icons/providers/fireworks.svg b/public/icons/providers/fireworks.svg new file mode 100644 index 0000000..e2fa54a --- /dev/null +++ b/public/icons/providers/fireworks.svg @@ -0,0 +1,8 @@ + + + diff --git a/public/icons/providers/groq.svg b/public/icons/providers/groq.svg new file mode 100644 index 0000000..1aa6182 --- /dev/null +++ b/public/icons/providers/groq.svg @@ -0,0 +1 @@ +Groq diff --git a/public/icons/providers/manifest.svg b/public/icons/providers/manifest.svg new file mode 100644 index 0000000..d6af3fe --- /dev/null +++ b/public/icons/providers/manifest.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons/providers/mistral.svg b/public/icons/providers/mistral.svg new file mode 100644 index 0000000..8e03e24 --- /dev/null +++ b/public/icons/providers/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/public/icons/providers/moonshot.svg b/public/icons/providers/moonshot.svg new file mode 100644 index 0000000..fb56ac1 --- /dev/null +++ b/public/icons/providers/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/public/icons/providers/nousresearch.svg b/public/icons/providers/nousresearch.svg new file mode 100644 index 0000000..900ce67 --- /dev/null +++ b/public/icons/providers/nousresearch.svg @@ -0,0 +1,21 @@ + + NousResearch + + + + diff --git a/public/icons/providers/nvidia.svg b/public/icons/providers/nvidia.svg new file mode 100644 index 0000000..fd8e6fa --- /dev/null +++ b/public/icons/providers/nvidia.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file diff --git a/public/icons/providers/ollama.svg b/public/icons/providers/ollama.svg new file mode 100644 index 0000000..9ff0194 --- /dev/null +++ b/public/icons/providers/ollama.svg @@ -0,0 +1 @@ +Ollama diff --git a/public/icons/providers/openrouter.svg b/public/icons/providers/openrouter.svg new file mode 100644 index 0000000..f20682c --- /dev/null +++ b/public/icons/providers/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/public/icons/providers/qwen.svg b/public/icons/providers/qwen.svg new file mode 100644 index 0000000..eff5b37 --- /dev/null +++ b/public/icons/providers/qwen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/providers/xai.svg b/public/icons/providers/xai.svg new file mode 100644 index 0000000..c61f570 --- /dev/null +++ b/public/icons/providers/xai.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 95bcc75..c1980c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,13 @@ import { type ApiFormat, type ApiFormatId, } from './formats'; +import { + PROVIDERS, + PROVIDER_BY_ID, + DEFAULT_PROVIDER_ID, + CUSTOM_PROVIDER, + type Provider, +} from './providers'; import { partitionHeaders, sendRequest, sendRequestStreaming, type SendResult } from './send'; import { appendHistory, @@ -38,6 +45,7 @@ const STORAGE = { profile: 'wingman:profile', format: 'wingman:format', stream: 'wingman:stream', + provider: 'wingman:provider', }; // API keys are stored in sessionStorage (cleared on tab close) instead of @@ -102,13 +110,32 @@ function defaultBaseUrl(): string { return ''; } -function resolveInitialFormat(): ApiFormatId { +// An external embed (?baseUrl=…, e.g. the Manifest dashboard drawer) is always +// the Custom/Manifest preset. Otherwise restore the last-picked preset. +function resolveInitialProvider(baseUrlParam: string | null): string { + if (baseUrlParam) return DEFAULT_PROVIDER_ID; + const stored = readStorage(STORAGE.provider, DEFAULT_PROVIDER_ID); + return PROVIDER_BY_ID[stored] ? stored : DEFAULT_PROVIDER_ID; +} + +function resolveInitialFormat(providerId: string): ApiFormatId { const param = readQueryParam('format'); if (param && FORMAT_BY_ID[param]) return param as ApiFormatId; + // A concrete provider preset dictates its own wire format. + const preset = PROVIDER_BY_ID[providerId]; + if (preset && preset.id !== DEFAULT_PROVIDER_ID) return preset.formatId; const stored = readStorage(STORAGE.format, DEFAULT_FORMAT_ID); return FORMAT_BY_ID[stored] ? (stored as ApiFormatId) : DEFAULT_FORMAT_ID; } +// Match a base URL back to a preset (used when restoring history) so the +// provider pill stays in sync; anything unrecognised is "Custom". +function providerForBaseUrl(url: string): string { + const trimmed = url.replace(/\/+$/, ''); + const match = PROVIDERS.find((p) => p.baseUrl && p.baseUrl === trimmed); + return match ? match.id : DEFAULT_PROVIDER_ID; +} + function resolveInitialProfile(formatId: ApiFormatId): string { const compatible = profilesForFormat(formatId); const stored = readStorage(STORAGE.profile, ''); @@ -119,11 +146,21 @@ function resolveInitialProfile(formatId: ApiFormatId): string { const App: Component = () => { const baseUrlParam = readQueryParam('baseUrl'); const apiKeyParam = readQueryParam('apiKey'); - const initialBaseUrl = baseUrlParam ?? readStorage(STORAGE.baseUrl, defaultBaseUrl()); + const initialProviderId = resolveInitialProvider(baseUrlParam); + const initialProvider = PROVIDER_BY_ID[initialProviderId] ?? CUSTOM_PROVIDER; + // For a concrete preset, the base URL comes from the catalog (the field is + // only free-text in Custom mode, so it can't drift). Custom falls back to the + // ?baseUrl= param, stored value, or the localhost dev guess. + const initialBaseUrl = + baseUrlParam ?? + (initialProvider.id !== DEFAULT_PROVIDER_ID + ? initialProvider.baseUrl + : readStorage(STORAGE.baseUrl, defaultBaseUrl())); const initialApiKey = apiKeyParam ?? readStorage(STORAGE.apiKey, ''); if (baseUrlParam) writeStorage(STORAGE.baseUrl, baseUrlParam); if (apiKeyParam) writeStorage(STORAGE.apiKey, apiKeyParam); - const initialFormatId = resolveInitialFormat(); + const initialFormatId = resolveInitialFormat(initialProviderId); + const [providerId, setProviderId] = createSignal(initialProviderId); const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); const [apiKey, setApiKey] = createSignal(initialApiKey); const [model, setModel] = createSignal(readStorage(STORAGE.model, 'auto')); @@ -131,6 +168,7 @@ const App: Component = () => { const [profileId, setProfileId] = createSignal(resolveInitialProfile(initialFormatId)); const [stream, setStream] = createSignal(readStorage(STORAGE.stream, '0') === '1'); + const provider = createMemo(() => PROVIDER_BY_ID[providerId()] ?? CUSTOM_PROVIDER); const format = createMemo(() => FORMAT_BY_ID[formatId()] ?? FORMATS[0]!); const availableProfiles = createMemo(() => profilesForFormat(formatId())); const profile = createMemo( @@ -165,7 +203,9 @@ const App: Component = () => { createEffect(() => { const url = baseUrl().trim(); const path = format().healthPath; - if (!url || !path) { + // Only the Manifest gateway exposes `/api/v1/health`; probing a public + // provider host (api.openai.com, …) would 404 and show a false "unreachable". + if (provider().id !== DEFAULT_PROVIDER_ID || !url || !path) { setHealthStatus({ kind: 'idle' }); return; } @@ -275,6 +315,34 @@ const App: Component = () => { writeStorage(STORAGE.stream, value ? '1' : '0'); }; + // Pick a provider preset: switch to its wire format and, for a concrete + // provider, fill the base URL + a usable default model. Selecting "Custom" + // resets to Manifest/BYO defaults so the user can type their own endpoint. + const selectProvider = (id: string) => { + const preset = PROVIDER_BY_ID[id]; + if (!preset || id === providerId()) return; + setProviderId(id); + writeStorage(STORAGE.provider, id); + setFormatSafely(preset.formatId); + if (preset.id === DEFAULT_PROVIDER_ID) { + persistAndSetBase(''); + persistAndSetModel('auto'); + } else { + persistAndSetBase(preset.baseUrl); + persistAndSetModel(preset.defaultModel); + } + }; + + // 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. + const handleBaseUrlInput = (value: string) => { + persistAndSetBase(value); + if (providerId() !== DEFAULT_PROVIDER_ID) { + setProviderId(DEFAULT_PROVIDER_ID); + writeStorage(STORAGE.provider, DEFAULT_PROVIDER_ID); + } + }; + const updateSystemPrompt = (value: string) => { setSystemPrompts({ ...systemPrompts(), [profile().id]: value }); }; @@ -381,6 +449,9 @@ const App: Component = () => { setProfileId(entry.profileId); writeStorage(STORAGE.profile, entry.profileId); persistAndSetBase(entry.baseUrl); + const restoredProvider = providerForBaseUrl(entry.baseUrl); + setProviderId(restoredProvider); + writeStorage(STORAGE.provider, restoredProvider); persistAndSetModel(entry.model); if (entry.streamed !== undefined) persistAndSetStream(entry.streamed); setSystemPrompts({ ...systemPrompts(), [entry.profileId]: entry.systemPrompt }); @@ -490,6 +561,9 @@ const App: Component = () => {
{ baseUrl={baseUrl()} baseUrlPlaceholder={format().placeholderBaseUrl ?? 'https://your-manifest.example.com'} apiKey={apiKey()} + apiKeyPlaceholder={provider().apiKeyPlaceholder} model={model()} - onBaseUrlChange={persistAndSetBase} + onBaseUrlChange={handleBaseUrlInput} onApiKeyChange={persistAndSetKey} onModelChange={persistAndSetModel} loading={loading()} diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index c5794bf..983f951 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -3,11 +3,16 @@ import HeaderEditor, { type HeaderEntry } from './HeaderEditor.jsx'; import CodeView from './CodeView.jsx'; import ProfileDropdown from './ProfileDropdown.jsx'; import FormatDropdown from './FormatDropdown.jsx'; +import ProviderDropdown from './ProviderDropdown.jsx'; import type { Profile, ProfileLang } from '../profiles'; import type { ApiFormat } from '../formats'; +import type { Provider } from '../providers'; import type { HealthStatus } from '../services/healthCheck'; interface Props { + providers: readonly Provider[]; + activeProviderId: string; + onSelectProvider: (id: string) => void; formats: readonly ApiFormat[]; activeFormatId: string; onSelectFormat: (id: string) => void; @@ -29,6 +34,7 @@ interface Props { apiKey: string; model: string; baseUrlPlaceholder: string; + apiKeyPlaceholder: string; onBaseUrlChange: (value: string) => void; onApiKeyChange: (value: string) => void; onModelChange: (value: string) => void; @@ -235,6 +241,14 @@ const Composer: Component = (props) => { {/* Expanded panels appear above the textarea */}
+
+ Provider preset + +