customProviderNameIssues accepts any string that isn't a built-in id, isn't default or custom, and isn't __proto__:
export function customProviderNameIssues(name: string): readonly string[] {
const issues: string[] = [];
if (isBuiltinProviderId(name)) { ... }
if ((RESERVED_PROVIDER_KEYS as readonly string[]).includes(name)) { ... }
if (name === "__proto__") { ... }
return issues;
}
providersMapSchema backs that with z.record(z.string(), customProviderEntrySchema), and the generated providers.schema.json has "propertyNames": { "type": "string" }. So there is no character rule, no length cap, and all three existing checks are exact and case-sensitive.
The name then becomes the provider id, and the id gets embedded in delimited strings in several places. Three of those are defects today.
1. A colon mis-parses model selection
packages/ui/src/components/status-bar/items/ModelSelector.tsx builds `${providerId}:${modelId}` and parses it back by splitting on the first colon:
function parseModelValue(value: string): { providerId: string; modelId: string } | null {
const colonIndex = value.indexOf(":");
if (colonIndex === -1) return null;
return { providerId: value.substring(0, colonIndex), modelId: value.substring(colonIndex + 1) };
}
A provider named My:Gateway yields providerId: "My" and modelId: "Gateway:the-real-model", so selecting a model silently targets a provider that doesn't exist.
The same delimiter makes a persisted credential key ambiguous. storageKeyFor returns `auth:${providerId}:${authMethodId}` and is documented as a stable persisted identifier written to ~/.posit/ai/auth/data.json. Nothing splits it back today, so this is aliasing risk rather than a live mis-parse, but it's the same character in a value that has to stay stable.
NodeModelService also invalidates its routing cache by prefix (key.startsWith(\${providerId}:`)), so a provider named fooclearing its cache also clears entries belonging to a provider namedfoo:bar`.
2. A dot splits a settings path
fix-base-url-settings.ts builds a VS Code configuration section from the provider's auth id:
const configKey = CONFIG_KEY_OVERRIDES[mapping.authProviderId] ?? mapping.authProviderId;
const inspection = config.inspect<string>(`${configKey}.baseUrl`);
...
await config.update(`${configKey}.baseUrl`, newUrl, target);
For a custom entry authProviderId is the raw user name, so a dot adds segments to that path. Latent rather than live, because fixBaseUrlSettings iterates only built-in providers today, but it's one loop away.
3. Case-only differences collapse onto built-ins
positronModelService.canonicalProviderId lowercases before canonicalizing:
private static canonicalProviderId(model: Pick<ModelInfo, "providerId">): string {
const providerId = model.providerId.toLowerCase();
return CANONICAL_PROVIDER_IDS.get(providerId) ?? providerId;
}
isBuiltinProviderId is exact, so Anthropic is a legal custom name, and then this collapses it onto the built-in anthropic. That's the same silent model loss already reported for names matching an auth provider id, reached through case instead of exact match.
Separately, two custom entries named Gateway and gateway are both legal and coexist on disk (CustomProviderActivationService.add guards with Object.hasOwn, which is case-sensitive), then collapse to one here.
Proposed change
In customProviderNameIssues:
- Reject
: and reject ., with messages naming the character.
- Add a length cap. 64 characters is generous for a label and well inside every key and path this ends up in.
- Make the built-in and reserved-key checks case-insensitive, so
Anthropic and Default are rejected the way anthropic and default already are.
Leave spaces alone. They're a supported case with fixtures throughout both products' tests, and the friendly names users actually type have spaces in them.
A case-insensitive duplicate check is a separate matter and doesn't belong in this function, which sees one name at a time. If it's wanted, it goes in CustomProviderActivationService.add, where the existing key set is known.
One thing this is deliberately not waiting for. An optional displayName on a custom entry, so the human label and the provider id could differ, would make a stricter id much easier to live with: the id could be a slug and the label could be anything. That's a bigger schema change and a separate conversation, and while the entry key doubles as both, the key is what has to be safe. Worth doing this either way.
One risk worth deciding on
These rules would make some existing on-disk entries invalid, since anyone who already has a provider named My:Gateway wrote it under today's rules.
The tolerant read path already handles this shape: salvage-config.ts runs the same policy and reports a bad key without losing valid siblings, which load-config.test.ts covers ("reports a raw unsafe custom-provider key without losing valid siblings"). So an existing entry would degrade to a reported issue rather than a hard load failure.
If that's still too sharp, the alternative is applying the new rules on the write path (mintCustomProviderId and the Add wire schema) while leaving the read path on today's rules. That stops new bad names without invalidating anything already on disk. Either is fine from Positron's side; the write-strict option is the safer default.
Not asking for
- A slug rule or any normalization. Names stay verbatim; this is validation only.
- A ban on commas. Positron joins provider ids on commas in one place, and that's ours to fix.
- Anything on built-in provider blocks.
customProviderNameIssuesaccepts any string that isn't a built-in id, isn'tdefaultorcustom, and isn't__proto__:providersMapSchemabacks that withz.record(z.string(), customProviderEntrySchema), and the generatedproviders.schema.jsonhas"propertyNames": { "type": "string" }. So there is no character rule, no length cap, and all three existing checks are exact and case-sensitive.The name then becomes the provider id, and the id gets embedded in delimited strings in several places. Three of those are defects today.
1. A colon mis-parses model selection
packages/ui/src/components/status-bar/items/ModelSelector.tsxbuilds`${providerId}:${modelId}`and parses it back by splitting on the first colon:A provider named
My:GatewayyieldsproviderId: "My"andmodelId: "Gateway:the-real-model", so selecting a model silently targets a provider that doesn't exist.The same delimiter makes a persisted credential key ambiguous.
storageKeyForreturns`auth:${providerId}:${authMethodId}`and is documented as a stable persisted identifier written to~/.posit/ai/auth/data.json. Nothing splits it back today, so this is aliasing risk rather than a live mis-parse, but it's the same character in a value that has to stay stable.NodeModelServicealso invalidates its routing cache by prefix (key.startsWith(\${providerId}:`)), so a provider namedfooclearing its cache also clears entries belonging to a provider namedfoo:bar`.2. A dot splits a settings path
fix-base-url-settings.tsbuilds a VS Code configuration section from the provider's auth id:For a custom entry
authProviderIdis the raw user name, so a dot adds segments to that path. Latent rather than live, becausefixBaseUrlSettingsiterates only built-in providers today, but it's one loop away.3. Case-only differences collapse onto built-ins
positronModelService.canonicalProviderIdlowercases before canonicalizing:isBuiltinProviderIdis exact, soAnthropicis a legal custom name, and then this collapses it onto the built-inanthropic. That's the same silent model loss already reported for names matching an auth provider id, reached through case instead of exact match.Separately, two custom entries named
Gatewayandgatewayare both legal and coexist on disk (CustomProviderActivationService.addguards withObject.hasOwn, which is case-sensitive), then collapse to one here.Proposed change
In
customProviderNameIssues::and reject., with messages naming the character.AnthropicandDefaultare rejected the wayanthropicanddefaultalready are.Leave spaces alone. They're a supported case with fixtures throughout both products' tests, and the friendly names users actually type have spaces in them.
A case-insensitive duplicate check is a separate matter and doesn't belong in this function, which sees one name at a time. If it's wanted, it goes in
CustomProviderActivationService.add, where the existing key set is known.One thing this is deliberately not waiting for. An optional
displayNameon a custom entry, so the human label and the provider id could differ, would make a stricter id much easier to live with: the id could be a slug and the label could be anything. That's a bigger schema change and a separate conversation, and while the entry key doubles as both, the key is what has to be safe. Worth doing this either way.One risk worth deciding on
These rules would make some existing on-disk entries invalid, since anyone who already has a provider named
My:Gatewaywrote it under today's rules.The tolerant read path already handles this shape:
salvage-config.tsruns the same policy and reports a bad key without losing valid siblings, whichload-config.test.tscovers ("reports a raw unsafe custom-provider key without losing valid siblings"). So an existing entry would degrade to a reported issue rather than a hard load failure.If that's still too sharp, the alternative is applying the new rules on the write path (
mintCustomProviderIdand the Add wire schema) while leaving the read path on today's rules. That stops new bad names without invalidating anything already on disk. Either is fine from Positron's side; the write-strict option is the safer default.Not asking for