On this page
- Separate portable intent from native controls
- A practical request model
- Provider extensions should be typed
- Do not invent false cross-provider equivalence
- Capability-gate settings before display
- Validation belongs in more than one layer
- UI validation
- Capability validation
- Adapter validation
- Omit unsupported fields instead of sending defaults
- Distinguish unset from explicit default
- Persist intent with the conversation when reproducibility matters
- Version provider-specific settings
- Treat unknown saved fields carefully
- Provider-specific headers are settings too
- Reasoning controls are a good example
- Sampling controls can become mutually exclusive
- Routing controls are not generation controls
- Safety settings should not be normalized carelessly
- Storage controls are lifecycle policy
- Schema features belong to output requirements
- The adapter is a compiler
- Keep wire objects out of the UI
- Test parameter matrices, not individual fields
- Log resolved settings without secrets
- Migration should favor intent over wire history
- Where BYOKchat fits
- Further reading
Multi-provider AI clients eventually hit a design trap: every provider has settings that the others do not.
At first the shared request type looks clean:
interface GenerationOptions {
temperature?: number;
maxOutputTokens?: number;
stream?: boolean;
}
Then requirements arrive:
reasoning effort
thinking budget
provider routing
safety settings
prompt caching controls
service tier
seed
log probabilities
hosted tools
response storage
background execution
The tempting response is to keep adding fields until one giant struct contains every option from every API.
That creates a fragile abstraction.
Separate portable intent from native controls
Some concepts are genuinely cross-provider:
selected model
streaming desired
output budget
user-visible system instructions
tools needed
structured output requirement
Others are provider-specific implementations:
OpenRouter provider routing
Gemini safety settings
Anthropic beta headers
DeepSeek thinking object
OpenAI service tier
Represent those layers separately.
A practical request model
For example:
interface GenerationRequest {
model: ModelReference;
conversation: PortableConversation;
intent: PortableGenerationIntent;
extensions: ProviderExtensions;
}
where:
interface PortableGenerationIntent {
streaming: boolean;
maxOutputTokens?: number;
tools?: PortableTool[];
structuredOutput?: PortableSchemaRequirement;
reasoning?: PortableReasoningIntent;
}
and provider-specific settings live elsewhere.
Provider extensions should be typed
Avoid an unstructured dictionary when possible:
extras: Record<string, any>
It is flexible but creates runtime errors and makes migration difficult.
Prefer typed variants:
type ProviderExtensions =
| { provider: "anthropic"; value: AnthropicOptions }
| { provider: "gemini"; value: GeminiOptions }
| { provider: "openrouter"; value: OpenRouterOptions }
| { provider: "deepseek"; value: DeepSeekOptions }
| { provider: "custom"; value: CustomProviderOptions };
The adapter can then compile-time validate its own fields.
Do not invent false cross-provider equivalence
Two fields with similar names may not mean the same thing.
For example:
reasoning_effort = high
thinking_level = high
thinking budget = N tokens
may all influence reasoning, but their provider semantics are not necessarily equivalent.
A generic UI can expose a broad user intent only if the mapping is defensible.
Otherwise show the provider-native control.
Capability-gate settings before display
A settings panel should be driven by:
connection
+ selected model
+ endpoint family
+ mode
→ supported controls
Do not show every advanced field and wait for the API to reject unsupported combinations.
The UI can hide, disable, or explain controls that do not apply.
See Capability Detection in Multi-Model AI Apps.
Validation belongs in more than one layer
Use three levels:
UI validation
Prevent obviously invalid values:
negative token limit
unknown enum
malformed JSON schema
Capability validation
Reject combinations the selected model/API cannot support.
Adapter validation
Ensure the final wire request satisfies provider-specific constraints.
The server remains the final authority, but many errors should be caught before network I/O.
Omit unsupported fields instead of sending defaults
A common compatibility bug is serializing every option with a default value:
{
"temperature": 1,
"top_p": 1,
"reasoning_effort": "medium",
"seed": 0
}
Even if the user did not choose those settings.
That can:
- trigger provider validation;
- disable a model mode;
- prevent caching;
- alter behavior;
- create false assumptions about support.
Prefer sparse encoding: send only fields intentionally active for the selected provider/model.
Distinguish unset from explicit default
These are semantically different:
user did not specify temperature
user explicitly selected provider default
user explicitly selected 1.0
Your persistence model may need an automatic state rather than forcing a number.
That lets provider defaults evolve without old chats silently pinning stale values.
Persist intent with the conversation when reproducibility matters
If a chat turn used a special setting, store enough information to explain or regenerate it later.
Useful request metadata can include:
provider
model ID
endpoint family
portable intent
provider extension version
resolved native settings
Do not store credentials with that record.
Version provider-specific settings
Settings schemas evolve.
A persisted object can include:
{
"provider": "example",
"version": 2,
"options": {
"someNewField": true
}
}
On app upgrade, migrate old data deliberately.
This is safer than decoding arbitrary dictionaries forever.
Treat unknown saved fields carefully
Forward compatibility is useful.
If a newer app writes an extension field and an older app opens the same backup, the older app should not necessarily destroy it on save.
Possible strategies:
- preserve unknown JSON alongside typed fields;
- version backups and reject unsupported destructive edits;
- keep raw provider metadata separate from editable settings.
Choose intentionally.
Provider-specific headers are settings too
Routing or feature headers can be part of provider configuration.
Separate:
non-secret feature header
secret credential header
Protected header values belong in secure storage and should never be copied into conversation metadata.
Reasoning controls are a good example
A portable UI might expose:
Reasoning: Auto / Off / Low / Medium / High
The adapter can map this only when the provider/model supports a meaningful equivalent.
If a provider has a unique mode such as a token budget or special beta toggle, preserve that natively rather than pretending it is exactly High.
See Reasoning Effort Explained.
Sampling controls can become mutually exclusive
Some reasoning modes ignore or reject normal sampling parameters.
Capability rules can express this:
if model.reasoningMode == .thinking {
disable(.temperature)
disable(.topP)
}
The adapter should enforce the same rule even if the UI somehow sends an old saved combination.
Routing controls are not generation controls
OpenRouter-style provider ordering/fallback settings affect where a request runs, not directly what the model should generate.
Keep them in a routing policy object:
request.routing
rather than mixing them with:
request.generation
This separation makes analytics and debugging much clearer.
Safety settings should not be normalized carelessly
Provider safety controls differ in taxonomy, defaults, and enforcement.
Do not create a universal slider like:
Safety: 0–100
unless you can define exactly what it means across providers.
It is usually better to expose provider-native advanced settings with clear labels.
Storage controls are lifecycle policy
Fields such as:
store response
background execution
previous response/interaction ID
change persistence and continuation semantics.
They belong closer to request lifecycle/state policy than to creative generation settings.
Your architecture should reflect that distinction.
Schema features belong to output requirements
Structured output is not merely another numeric knob.
Represent it as a requirement:
structuredOutput: {
schema,
strictness
}
Then capability validation can decide whether the selected provider can satisfy it.
If not, fail before sending rather than quietly falling back to unstructured text.
The adapter is a compiler
A useful mental model is:
portable intent + native extension + capabilities
↓
provider adapter
↓
wire request
The adapter behaves like a compiler:
- validates inputs;
- resolves defaults;
- rejects impossible combinations;
- translates semantic intent;
- emits provider-specific JSON/headers.
This is stronger than a serializer.
Keep wire objects out of the UI
Avoid binding forms directly to SDK request types.
SDK types change and often contain fields irrelevant to your product.
Instead:
UI model → product settings → adapter → SDK/wire model
That keeps provider SDK updates from rippling through every view.
Test parameter matrices, not individual fields
Most bugs appear in combinations:
reasoning + temperature
tools + structured output
streaming + tool arguments
background + storage disabled
fallback routing + required parameter
image input + unsupported model
Build table-driven tests for valid and invalid matrices.
Log resolved settings without secrets
For diagnostics, it can be useful to record:
model = x
reasoning = high
stream = true
tools = enabled
routing = fallback allowed
Do not log:
- API keys;
- protected headers;
- prompts;
- tool arguments/results unless the user explicitly opts into sensitive diagnostics.
Migration should favor intent over wire history
Provider APIs evolve.
If you only persist old raw request JSON, future clients may struggle to reinterpret it.
Persist the user/product intent plus optional raw metadata.
Then a newer adapter can generate a current request while historical wire data remains available for audit/debugging.
Where BYOKchat fits
A multi-provider client can keep common chat controls consistent while giving each provider room for native advanced settings. Provider/model capability profiles decide which controls are visible, and adapters compile those choices into correct requests.
That prevents the common outcome where a supposedly “unified” settings panel becomes a union of every API field ever invented.