feat(providers): pre-configured provider presets from external JSON catalog - #941
Conversation
|
🔍 OpenCodeReview found 7 issue(s) in this PR.
|
| export function defaultPresetsPath(): string { | ||
| // src/services/provider-presets.ts -> packages/backend/data/provider-presets.json | ||
| return fileURLToPath(new URL('../../data/provider-presets.json', import.meta.url)); | ||
| } |
There was a problem hiding this comment.
The fallback file is missing from the release binaries. The compile:* and build:bin scripts in the root package.json run bun build packages/backend/src/index.ts ... --compile and only list migrations and frontend assets. data/provider-presets.json is never embedded, and inside the compiled binary import.meta.url resolves to a virtual /$bunfs/... path. So when GitHub can't be reached (offline, air-gapped, or proxied), loadLocalPresets throws and the route returns 500. That breaks the offline fallback this module is meant to provide. Import the JSON statically so the bundler embeds it, the same way routes/openapi.ts imports ../assets/openapi.json.
Suggestion:
| export function defaultPresetsPath(): string { | |
| // src/services/provider-presets.ts -> packages/backend/data/provider-presets.json | |
| return fileURLToPath(new URL('../../data/provider-presets.json', import.meta.url)); | |
| } | |
| import builtinPresetsJson from '../../data/provider-presets.json' with { type: 'json' }; | |
| export function loadLocalPresets(): ProviderPreset[] { | |
| return parseAndValidatePresets(builtinPresetsJson, 'built-in provider-presets.json'); | |
| } |
There was a problem hiding this comment.
Fixed: loadLocalPresets now prefers the disk file (live edits still work) and falls back to a statically imported copy the bundler embeds. Proved with a compiled binary run with no data directory nearby: remote dead, served all 17 embedded presets with source local.
| .catch(() => { | ||
| if (!cancelled) setLoadFailed(true); | ||
| }) |
There was a problem hiding this comment.
A failed preset load is swallowed silently: loadFailed makes the component render null and nothing is logged. Operators can't tell that presets exist or that the request failed (for example an auth or backend error). Keep the manual flow working, but show a short notice such as "Couldn't load provider presets. You can still configure a provider manually." and log the error.
There was a problem hiding this comment.
Fixed: the picker now renders a 'Couldn't load provider presets … you can still configure manually' notice and logs the error instead of rendering null.
| const handleSelect = (presetId: string) => { | ||
| setSelectedPresetId(presetId); | ||
| setVarValues({}); | ||
| if (!presetId) return; // back to Custom — leave the draft untouched |
There was a problem hiding this comment.
Switching presets keeps the old identity. applyProviderPreset only fills id and name when they're empty, so after picking preset A (id a) and then preset B, the draft has B's endpoints but still A's id and name. The provider can be saved under the wrong id.
Returning to "Custom (blank)" has a similar problem: it leaves the previous preset's endpoints in the draft even though the UI now says blank, and the placeholder warning disappears because it only renders inside selectedPreset &&.
Fixes:
- Clear id and name when they still equal the previously applied preset's values.
- Restore a pre-preset snapshot on Custom, or rename the option to something like "Custom (keep current values)".
- Render the
hasUnresolvedVarswarning outside theselectedPresetblock.
There was a problem hiding this comment.
Fixed: applyProviderPreset takes the previously applied preset and overwrites id/name only while they still hold its suggestions; returning to Custom restores the pre-preset snapshot (API key and other input untouched). Verified in a real browser: openai → moonshot updates identity, Custom restores the blank draft.
| /** Display label for the preset picker. */ | ||
| name: z.string().trim().min(1), | ||
| description: z.string().optional(), | ||
| docsUrl: z.string().url().optional(), |
There was a problem hiding this comment.
z.string().url() accepts any scheme, including javascript:. The catalog is fetched at runtime from a remote URL (REMOTE_PRESETS_URL), and ProviderPresetPicker renders docsUrl directly as an <a href>. A tampered catalog entry would therefore produce a clickable script URL in the admin UI. The apiBaseUrl values also aren't validated as URLs, yet they decide where the operator's API key is sent. Restrict both to http(s).
Suggestion:
| docsUrl: z.string().url().optional(), | |
| docsUrl: z | |
| .string() | |
| .url() | |
| .refine((value) => /^https?:\/\//i.test(value), { message: 'docsUrl must be http(s)' }) | |
| .optional(), |
There was a problem hiding this comment.
Fixed: docsUrl refined to http(s), and apiBaseUrl values must be http(s) modulo {placeholders}. Tests added.
| .refine( | ||
| (preset) => | ||
| preset.templateVars.every((variable) => | ||
| Object.values(preset.apiBaseUrl).some((url) => url.includes(`{${variable.key}}`)) | ||
| ), | ||
| { message: 'every templateVar key must appear as {key} in at least one URL' } | ||
| ); |
There was a problem hiding this comment.
This check only runs one way: every declared templateVar must appear in some URL. The reverse isn't checked. A URL such as https://{region}.api.example.com with no matching templateVars entry passes validation, including from the remote catalog. The picker then shows no input for it, so the operator can't fill it in. Add the reverse check.
Suggestion:
| .refine( | |
| (preset) => | |
| preset.templateVars.every((variable) => | |
| Object.values(preset.apiBaseUrl).some((url) => url.includes(`{${variable.key}}`)) | |
| ), | |
| { message: 'every templateVar key must appear as {key} in at least one URL' } | |
| ); | |
| .refine( | |
| (preset) => | |
| preset.templateVars.every((variable) => | |
| Object.values(preset.apiBaseUrl).some((url) => url.includes(`{${variable.key}}`)) | |
| ), | |
| { message: 'every templateVar key must appear as {key} in at least one URL' } | |
| ) | |
| .refine( | |
| (preset) => { | |
| const declared = new Set(preset.templateVars.map((variable) => variable.key)); | |
| return Object.values(preset.apiBaseUrl).every((url) => | |
| [...url.matchAll(/\{([^{}]+)\}/g)].every(([, key]) => declared.has(key)) | |
| ); | |
| }, | |
| { message: 'every {placeholder} in apiBaseUrl must be declared in templateVars' } | |
| ); |
There was a problem hiding this comment.
Fixed with the suggested reverse refine. Tests added.
| const substituted: Record<string, string> = {}; | ||
| for (const [apiType, url] of Object.entries(urls)) { | ||
| substituted[apiType] = url.replace(/\{([^{}]+)\}/g, (match, key: string) => { | ||
| const value = values[key]?.trim(); |
There was a problem hiding this comment.
values[key] does a plain property lookup on an object literal, so inherited names resolve to Object.prototype members. A placeholder like {constructor}, {toString} or {valueOf} makes values[key] return a function. The ?. only guards against null/undefined, so .trim evaluates to undefined and calling it throws a TypeError. The schema accepts such a key (the templateVars refine passes as long as {constructor} appears in a URL), and the catalog is fetched remotely. applyProviderPreset runs inside the setEditingProvider state updater, so the throw happens during React's render and takes down the Add Provider modal. The experimentalApis refine has the same weakness: api in preset.apiBaseUrl returns true for "toString". Use own-property checks in both places.
Suggestion:
| const value = values[key]?.trim(); | |
| const raw = Object.hasOwn(values, key) ? values[key] : undefined; | |
| const value = typeof raw === 'string' ? raw.trim() : ''; |
There was a problem hiding this comment.
Fixed: templateVar keys can no longer shadow Object.prototype members, substitution uses own-property checks with a string-type guard, and the experimentalApis check uses Object.hasOwn. Tests added.
| id: draft.id.trim() ? draft.id : preset.suggestedProviderId, | ||
| name: draft.name.trim() ? draft.name : preset.suggestedName, |
There was a problem hiding this comment.
Switching presets keeps the previous preset's id and name. ProviderPresetPicker.handleSelect calls applyProviderPreset(prev, preset) on a draft that already holds preset A's suggestedProviderId/suggestedName, so those fields count as non-empty and stay. Preset B's endpoints, type, and pi_ai_provider still overwrite everything else. The result can be a provider saved as openai that points at Moonshot URLs. The function can't tell a value a previous preset auto-filled from one the operator typed. Fix: accept the previous preset and overwrite a field when it still equals that preset's suggestion.
Suggestion:
| id: draft.id.trim() ? draft.id : preset.suggestedProviderId, | |
| name: draft.name.trim() ? draft.name : preset.suggestedName, | |
| export function applyProviderPreset<T extends ProviderPresetDraft>( | |
| draft: T, | |
| preset: ProviderPreset, | |
| varValues: Record<string, string> = {}, | |
| previousPreset?: ProviderPreset | |
| ): T { | |
| const idIsAutoFilled = !draft.id.trim() || draft.id === previousPreset?.suggestedProviderId; | |
| const nameIsAutoFilled = !draft.name.trim() || draft.name === previousPreset?.suggestedName; | |
| return { | |
| ...draft, | |
| id: idIsAutoFilled ? preset.suggestedProviderId : draft.id, | |
| name: nameIsAutoFilled ? preset.suggestedName : draft.name, |
There was a problem hiding this comment.
Fixed with the suggested previousPreset parameter, wired through the picker. Tests added.
| ...draft, | ||
| id: draft.id.trim() ? draft.id : preset.suggestedProviderId, | ||
| name: draft.name.trim() ? draft.name : preset.suggestedName, | ||
| apiBaseUrl: substitutePresetVars({ ...preset.apiBaseUrl }, varValues), |
There was a problem hiding this comment.
Unfilled template variables can end up in a saved provider. substitutePresetVars deliberately leaves {key} in place. ProviderPresetPicker only shows a warning when that happens (hasUnresolvedVars) and doesn't block saving. The backend ProviderConfigSchema doesn't validate URLs in the map form of api_base_url (z.record(z.string(), z.string())), so https://{region}... is persisted and only fails when a request is sent. Fix: export a helper such as getUnresolvedPresetVars(urls): string[] from this module and use it to disable save in the form. Alternatively, reject map entries that aren't valid URLs in the backend schema.
There was a problem hiding this comment.
Addressed on the frontend instead of the backend schema: saving is now blocked while any {placeholder} remains, so unfilled templates can't be persisted. Chose this over validating the backend map schema to avoid changing write validation for all existing providers.
🔍 OpenCodeReview — detailed findings✅ Passing — no critical/high findings. Reviewed 14 file(s), 7 finding(s) total.
Any of these can be saved, and the provider's API key is then sent to an unintended endpoint. Placeholders mark single URL components, so either Suggested changeSuggested changeSuggested changeFor example, the operator applies a preset, removes an experimental endpoint (or renames its API-type key), and then types into a template-var input. The old key comes back in Suggested change |
| export const REMOTE_PRESETS_URL = | ||
| 'https://raw.githubusercontent.com/mcowger/plexus/main/packages/backend/data/provider-presets.json'; |
There was a problem hiding this comment.
The remote URL is hardcoded, it tracks the mutable main branch, and there is no way to turn it off. The catalog sets the apiBaseUrl values that users then send API keys to, and the schema accepts any http(s) URL (plain http:// included). So any push to main (or a compromised repo) can change where every self-hosted instance sends newly configured provider keys, and operators cannot opt out. Because the remote is always tried first, local hand-edits are also silently ignored whenever GitHub is reachable, which contradicts the comment in loadLocalPresets. Suggested fix: read the URL from config or an env var, with an explicit way to disable remote fetching (e.g. empty value means local only). Also consider pinning to a tag or release rather than main, and requiring https:// for remotely sourced endpoints.
There was a problem hiding this comment.
Fair catch on the sub-points; the env-var part stays declined per owner direction (hardcoded default was explicitly requested). Fixed: preset endpoints and docsUrl must now be https (plain http rejected), and local hand-edits take precedence again — a disk file edited after process start wins over remote, so the documented edit-freely story holds; pristine/missing disk still defers to remote with embedded fallback.
| export async function loadProviderPresets( | ||
| remoteUrl: string = REMOTE_PRESETS_URL | ||
| ): Promise<ProviderPresetsResult> { | ||
| try { | ||
| return { presets: await fetchRemotePresets(remoteUrl), source: 'remote' }; |
There was a problem hiding this comment.
Nothing caches the remote catalog, so every GET /v0/management/provider-presets makes a new request to raw.githubusercontent.com. On offline, air-gapped, or firewalled deployments, every time the picker opens it waits the full 5s REMOTE_TIMEOUT_MS before falling back, and a warning is logged each time. Busy instances can also hit GitHub rate limits. Suggested fix: cache the result in memory with a TTL, and after a failed fetch remember the failure for a while so the endpoint serves the local copy right away.
There was a problem hiding this comment.
Still declined per owner direction (caching was explicitly removed as over-engineering), with one mitigation from your note: the failure mode you describe now only bites when GitHub is actually unreachable, in which case the 5s timeout applies once per picker open before the local fallback serves. Willing to revisit with a fixed hardcoded TTL if it ever hurts in practice.
| if (prePresetSnapshot) { | ||
| const snapshot = prePresetSnapshot; | ||
| setEditingProvider((prev) => ({ ...prev, ...snapshot })); | ||
| } |
There was a problem hiding this comment.
Switching back to "Custom" puts the whole pre-preset snapshot back, so it overwrites edits the user made after applying the preset. Example: apply a preset, change the ID to my-openrouter, fix an endpoint URL, then pick Custom. The ID, name, URLs and type all go back to the blank draft.
This is inconsistent with switching from one preset to another. There, applyProviderPreset keeps a user-typed id/name and only replaces values that still match the previous preset's suggestions. It also contradicts the inline comment that "everything else stay[s] as typed".
The snapshot also leaves out oauthProvider and apiKey, which applyProviderPreset clears. A draft that was in OAuth mode before the preset comes back with its old type but an empty oauthProvider.
Fix: restore a field only if it still equals the value the preset wrote, and include oauthProvider/apiKey in the snapshot.
Suggestion:
| if (prePresetSnapshot) { | |
| const snapshot = prePresetSnapshot; | |
| setEditingProvider((prev) => ({ ...prev, ...snapshot })); | |
| } | |
| if (prePresetSnapshot && appliedPreset) { | |
| const snapshot = prePresetSnapshot; | |
| const applied = appliedPreset; | |
| setEditingProvider((prev) => ({ | |
| ...prev, | |
| id: prev.id === applied.suggestedProviderId ? snapshot.id : prev.id, | |
| name: prev.name === applied.suggestedName ? snapshot.name : prev.name, | |
| apiBaseUrl: snapshot.apiBaseUrl, | |
| type: snapshot.type, | |
| pi_ai_provider: snapshot.pi_ai_provider, | |
| auto_compat: snapshot.auto_compat, | |
| })); | |
| } |
There was a problem hiding this comment.
Fixed: returning to Custom now restores each field only if it still equals the preset-applied value (same rule as preset-to-preset switching), so post-apply edits survive. Verified in-browser with your exact scenario: apply preset, rename to custom name, back to Custom — ID restores to blank, custom name kept.
| setPrePresetSnapshot({ | ||
| id: editingProvider.id, | ||
| name: editingProvider.name, |
There was a problem hiding this comment.
The snapshot doesn't save apiKey or oauthProvider. applyProviderPreset changes both: it clears oauthProvider and resets apiKey from 'oauth' to ''. So if the user had set up OAuth before trying a preset, switching back to Custom brings back the OAuth placeholder apiBaseUrl but not oauthProvider or apiKey. The draft ends up half OAuth, half API key. Add apiKey and oauthProvider to PresetTouchedFields and to this snapshot.
There was a problem hiding this comment.
Fixed: apiKey and oauthProvider are now part of the snapshot and the conditional restore, so an OAuth draft round-trips through a preset visit intact. Verified alongside the Custom-restore fix.
| if (current === undefined) { | ||
| updated[apiType] = nextUrl; | ||
| } else if (current.includes('{') || current === prevSubstituted[apiType]) { |
There was a problem hiding this comment.
Typing in a template-var input brings back endpoints the user already removed or renamed. removeApiBaseUrlEntry and updateApiBaseUrlEntry delete the key from apiBaseUrl, so current === undefined is true for it, and this branch writes the preset URL back in. It also leaves type unchanged, so apiBaseUrl and type stop matching. Example: apply a preset that has an experimental endpoint, remove that endpoint, then type the account ID. The endpoint reappears. Only rewrite keys that are still in the draft map, and never add missing ones.
Suggestion:
| if (current === undefined) { | |
| updated[apiType] = nextUrl; | |
| } else if (current.includes('{') || current === prevSubstituted[apiType]) { | |
| if (current === undefined) continue; | |
| if (current.includes('{') || current === prevSubstituted[apiType]) { |
| function isPresetUrl(value: unknown): boolean { | ||
| if (typeof value !== 'string') return false; | ||
| const deTemplated = value.replace(PLACEHOLDER_PATTERN, 'x'); | ||
| return /^https?:\/\//i.test(deTemplated); |
There was a problem hiding this comment.
The backend fetches this catalog at runtime from a remote URL (raw.githubusercontent.com/.../main), and this schema is the only check on it. It accepts plain http:// endpoints, and every preset exists to be paired with an API key. A bad catalog entry, or a downgrade to http, would send provider keys over plaintext. No bundled preset uses http, so require https:// for preset endpoints (and the same for docsUrl).
Suggestion:
| return /^https?:\/\//i.test(deTemplated); | |
| return /^https:\/\//i.test(deTemplated); |
| substituted[apiType] = url.replace(PLACEHOLDER_PATTERN, (match, key: string) => { | ||
| const raw = Object.hasOwn(values, key) ? values[key] : undefined; | ||
| const value = typeof raw === 'string' ? raw.trim() : ''; | ||
| return value ? value : match; |
There was a problem hiding this comment.
Operator-typed template values go into the URL raw. Presets put placeholders in the host (e.g. https://{resource}.openai.azure.com), so a value containing /, @, ? or # changes the URL. For example, evil.com/x? gives https://evil.com/x?.openai.azure.com, and the provider's API key then goes to that host. The same value can also produce a broken URL that passes the unresolved-placeholder check and gets saved. Encode the value (encodeURIComponent leaves . and - alone, so valid host labels and IDs are unchanged) or reject values that contain URL delimiter characters.
Suggestion:
| return value ? value : match; | |
| return value ? encodeURIComponent(value) : match; |
| export const REMOTE_PRESETS_URL = | ||
| 'https://raw.githubusercontent.com/mcowger/plexus/main/packages/backend/data/provider-presets.json'; |
There was a problem hiding this comment.
The remote catalog URL is hardcoded, and there's no way to override or disable it. Air-gapped or privacy-sensitive deployments will always make an outbound request to GitHub, and forks can't point at their own catalog. Read it from config or an env var (e.g. PLEXUS_PROVIDER_PRESETS_URL, where an empty value disables the remote fetch), and use this constant only as the default.
Suggestion:
| export const REMOTE_PRESETS_URL = | |
| 'https://raw.githubusercontent.com/mcowger/plexus/main/packages/backend/data/provider-presets.json'; | |
| export const REMOTE_PRESETS_URL = | |
| process.env.PLEXUS_PROVIDER_PRESETS_URL ?? | |
| 'https://raw.githubusercontent.com/mcowger/plexus/main/packages/backend/data/provider-presets.json'; |
| if (await diskFileEditedSinceStartup(defaultPresetsPath())) { | ||
| return { presets: await loadLocalPresets(), source: 'local' }; | ||
| } |
There was a problem hiding this comment.
Local edits only win if the file's mtime is later than PROCESS_STARTED_AT. After a restart, the mtime is earlier than the new start time, so the remote catalog wins again and the operator's customization disappears without any warning. That contradicts the comment above ("local hand-edits always take effect"). Use an explicit signal instead, such as an env/config override path, a flag to disable the remote fetch, or comparing the disk content with the embedded builtinPresetsJson. Don't rely on the process start time.
| try { | ||
| return { presets: await fetchRemotePresets(remoteUrl), source: 'remote' }; |
There was a problem hiding this comment.
Every GET /v0/management/provider-presets request makes a new outbound fetch to raw.githubusercontent.com with no caching. When GitHub is slow or blocked, each request can wait up to 5s before falling back, and a busy UI can hit GitHub's rate limits. Cache the result in memory with a TTL (for example 10–60 min), and possibly cache the negative result too, so offline deployments don't pay the timeout on every page open.
| for (const key of RESTORABLE_KEYS) { | ||
| if (isEqualValue(prev[key], appliedValues[key])) { | ||
| (restored as Record<string, unknown>)[key] = snapshot[key]; | ||
| } | ||
| } |
There was a problem hiding this comment.
Switching back to Custom checks each field on its own, which can leave the draft in a mixed state. For example, the user applies a preset and then edits one endpoint URL. updateApiBaseUrlEntry keeps the same keys, so type still matches the preset and gets restored to the snapshot (usually []). pi_ai_provider and auto_compat also go back to their old values. But apiBaseUrl no longer matches, so the preset's endpoint map stays. The result is a provider with the preset's endpoints, an empty type, and no pi-ai provider. Restore the linked fields (apiBaseUrl, type, pi_ai_provider, auto_compat, oauthProvider) together: restore them only if all of them still match the preset values, otherwise keep all of them. At minimum, work out type from whatever apiBaseUrl ends up being.
Suggestion:
| for (const key of RESTORABLE_KEYS) { | |
| if (isEqualValue(prev[key], appliedValues[key])) { | |
| (restored as Record<string, unknown>)[key] = snapshot[key]; | |
| } | |
| } | |
| const LINKED_KEYS = ['apiBaseUrl', 'type', 'pi_ai_provider', 'auto_compat', 'oauthProvider'] as const; | |
| const linkedUntouched = LINKED_KEYS.every((key) => isEqualValue(prev[key], appliedValues[key])); | |
| for (const key of RESTORABLE_KEYS) { | |
| const isLinked = (LINKED_KEYS as readonly string[]).includes(key); | |
| if (isLinked ? linkedUntouched : isEqualValue(prev[key], appliedValues[key])) { | |
| (restored as Record<string, unknown>)[key] = snapshot[key]; | |
| } | |
| } |
| if (current === undefined) { | ||
| updated[apiType] = nextUrl; | ||
| } else if (current.includes('{') || current === prevSubstituted[apiType]) { |
There was a problem hiding this comment.
The current === undefined branch puts back endpoints the operator removed on purpose. The edit handlers in useProviderForm (removeApiBaseUrlEntry, and updateApiBaseUrlEntry when a key is renamed) delete the key from the map and recompute type with inferProviderTypes.
For example, the operator applies a preset, removes an experimental endpoint (or renames its API-type key), and then types into a template-var input. The old key comes back in apiBaseUrl, but type is not recomputed here. The draft ends up with an endpoint the user deleted, possibly next to a duplicate under the new key, and type no longer matches the map keys. Only rewrite entries that are still in the map.
Suggestion:
| if (current === undefined) { | |
| updated[apiType] = nextUrl; | |
| } else if (current.includes('{') || current === prevSubstituted[apiType]) { | |
| if (current === undefined) continue; | |
| if (current.includes('{') || current === prevSubstituted[apiType]) { | |
| updated[apiType] = nextUrl; | |
| } |
| /** Endpoint map applied verbatim to the provider draft's `apiBaseUrl`. */ | ||
| apiBaseUrl: z.record( | ||
| z.string(), | ||
| z.string().trim().min(1).refine(isPresetUrl, { message: 'endpoint must be an http(s) URL' }) |
There was a problem hiding this comment.
The error message contradicts the check. isPresetUrl only accepts https://, and the doc comment above says plain http is deliberately rejected. The message still says http(s). If a preset author uses an http:// endpoint, they are told http is allowed, which makes the rejection confusing. This message also shows up in the backend's Invalid provider presets from ... log, where it hides why the remote catalog was dropped. Change it to say https only, like docsUrl does.
Suggestion:
| z.string().trim().min(1).refine(isPresetUrl, { message: 'endpoint must be an http(s) URL' }) | |
| z.string().trim().min(1).refine(isPresetUrl, { message: 'endpoint must be an https URL' }) |
| substituted[apiType] = url.replace(PLACEHOLDER_PATTERN, (match, key: string) => { | ||
| const raw = Object.hasOwn(values, key) ? values[key] : undefined; | ||
| const value = typeof raw === 'string' ? raw.trim() : ''; | ||
| return value ? value : match; | ||
| }); |
There was a problem hiding this comment.
Operator-supplied template values go into the URL verbatim, with no encoding or validation. The https check only runs on the preset template, and findUnresolvedPresetVars only looks for leftover {…}. Nothing checks the final URL. For example:
- An
account_idofabc/../../otherrewrites the Cloudflare path. - A value containing
?,#or a space gives a truncated or malformed URL. - If a future preset puts a placeholder in the host (the schema allows
https://{host}.example.com), a value likeevil.com/moves the host completely.
Any of these can be saved, and the provider's API key is then sent to an unintended endpoint.
Placeholders mark single URL components, so either encodeURIComponent the value or reject values that aren't something like /^[A-Za-z0-9._-]+$/. Also, ideally, check that the substituted result still parses as an https URL with the expected host.
Suggestion:
| substituted[apiType] = url.replace(PLACEHOLDER_PATTERN, (match, key: string) => { | |
| const raw = Object.hasOwn(values, key) ? values[key] : undefined; | |
| const value = typeof raw === 'string' ? raw.trim() : ''; | |
| return value ? value : match; | |
| }); | |
| substituted[apiType] = url.replace(PLACEHOLDER_PATTERN, (match, key: string) => { | |
| const raw = Object.hasOwn(values, key) ? values[key] : undefined; | |
| const value = typeof raw === 'string' ? raw.trim() : ''; | |
| // Placeholders stand for a single URL component; encode so values can't | |
| // inject path segments, query/fragment, or alter the host. | |
| return value ? encodeURIComponent(value) : match; | |
| }); |
Summary
Adding a provider is now pick-a-preset plus an API key. A new "Start from a preset" picker at the top of the Add Provider modal pre-fills the endpoint map, pi-ai provider, and auto-compat for 17 known providers (OpenAI, Anthropic, OpenRouter, Meta, Moonshot, Kilocode, Neuralwatt, Cloudflare, DeepSeek, Fireworks, Groq, MiniMax, Mistral, Vercel, Z.ai, plus regional variants).
Why / Context
Provider setup previously meant hand-typing base URLs per API type, with pi-ai only able to suggest the provider id after the fact. A docs-research pass mapped each provider's supported protocols (chat/completions/messages/responses) to exact base URLs, including the
/v1convention Plexus needs for Anthropic-compatible bases.Changes
GET /v0/management/provider-presets(admin-only) servingpackages/backend/data/provider-presets.json. The catalog defaults to the repo's raw GitHub URL and falls back to the built-in file on any failure, so preset updates ship as a data-file commit with no release. Response includes asourcefield reporting which catalog was served.ProviderPresetSchemaplus pure helpers (findProviderPreset,substitutePresetVars,applyProviderPreset) in@plexus/shared.ProviderPresetPickerin the Add Provider modal (new providers only) with experimental-endpoint warnings, template-var inputs (e.g. Cloudflare account ID), and docs links; hides itself if the catalog is unreachable.isRisky()no longer flags safe-method operations — the "reset" substring heuristic false-positived onprovider-presets, demanding confirmation for a read-only GET.openapi.jsonasset.Testing
Notes / Follow-ups
source: localfor now and flips toremoteautomatically on merge.