How to gracefully handle resource variants in your REST API

Bruce Hill
Software Engineer
As REST APIs evolve over time, it’s common to see a single parameter split out into groups of related parameters. An API that lets users choose a specific AI model as a string parameter, for example, may evolve into needing to specify different parameters for different AI models. One image model might require square ratios, another only widescreen, a third might add HDR or drop the seed
parameter entirely. If you expose all of these through a single endpoint, you quickly run into confusing validation problems and poor editor hints.
This post walks through four concrete API shapes that teams commonly consider, plus a “default model” variant. The goal is to balance type clarity, SDK ergonomics, and long-term maintainability.
The starting point
Here, an image generation API exposes a single endpoint that accepts different model names and parameters:
However, going forward, the API needs to have different settings available for each particular model. Some ratio
parameters are not valid for some models and different models do or don’t take a seed
parameter and there may be new parameters which only exist for some models, like an HDR rendering option.
Option 1: Common-denominator schema
The most backwards-compatible and smallest-change option is to keep the current schema, but make backwards-compatible extensions that add new fields as needed. This keeps the API the same:
If some models support different ratios than others, the backend will perform validation and send back an error response if there’s a mismatch between the model
and the ratio:
Common-denominator advantages
Fully backwards compatible and very simple to add new models.
Fewer and simpler types in the generated SDK code. There will be a lot of optional fields, but there will only be one non-union data type for the
generate_image
request. This plays nicely with languages like Go that don’t have the best support for discriminated unions.
Common-denominator Disadvantages
All validation occurs server side, so users won’t know if their code is right or wrong until runtime. This really hurts the user experience compared to type checking errors, or editor hints telling them which options are available or required for each model.
There are many ways that the inputs to the backend can be wrong, including complicated cross-dependencies between fields. This can be hard to maintain, especially as code gets more complicated.
Option 2: Discriminated unions
Instead of a single type with optional fields, the endpoint could take an anyOf
schema that explicitly enumerates the required and optional fields for each model type, using the model name as a discriminator.
Discriminated unions advantages
This is a really nice conceptual model that closely maps to the idea that
generate_image
is a verb andsettings
is a parameter that can be one of an enumerated list of things.If you expect users to have a
settings
variable that might be conditionally set to different options depending on runtime conditions, or a preset default option, this supports that case well. Some users may not care about which model is used, so you could perform a erategenerate_image(prompt)
without an explicitsettings
option and get a sensible result.The type checkers for most languages can produce good error checking in the editor and at compile time. You can get good editor suggestions for each option.
If the model configuration options are shared with other endpoints (e.g.
POST /v2/generate_image_infill
), the model configuration options can be factored out into a schema that is shared between them.
Discriminated unions disadvantages
This approach significantly increases SDK type complexity. There is now a special type for text-to-image settings, which is a union of many different model-specific types. The
client.generate_image(prompt:string, settings:ModelSettings)
SDK function now needs to refer to a custom union type, which can be very verbose in some languages.Some languages have lackluster support for discriminated unions, particularly in the language server protocols used in common editors. As a result, the quality of hinting in editors can be hit-or-miss.
Option 3: Separate endpoints at /v2/generate_image/{model}
If you split out each model into its own endpoint, it can fix some of the problems with the previous options, although it isn’t without its own drawbacks.
Separate endpoints advantages
Very low complexity for the types used in the SDK. Each model’s endpoint is a function with simple types for the arguments:
This will produce a good user experience in editors with type hinting, autosuggestions, type checking, and so on. After typing
client.generate_image.
, the user’s editor can suggest which models are available for text-to-image generation, and then which parameters that function takes.Adding a new model does not change any of the existing argument types or add any type complexity, it just introduces a new endpoint method. Modifying a model’s options is also similarly easy to reason about locally, since it only affects the type signature of one function.
Separate endpoints disadvantages
For models whose names are not valid identifiers, the generated SDK code will have to approximate the name (e.g.
zorg_6.0_plus_plus
will becomezorg_6_0_plus_plus
).The API will end up with a lot more methods with overlapping functionality.
Users will have to decide which model they want to use, since there won’t be an easy way to have a default option like
client.generate_image(prompt)
where you don’t specify which model to use. You could define a specific endpoint for that purpose likeclient.generate_image.default(prompt)
, but that would require an extra backend endpoint or custom code in the SDK. See Option 4A below for more details.It’s a little bit awkward to use the name of a model as a function, since function names typically describe the action being performed, rather than the thing that’s performing the action.
Option 4: Separate endpoints at /v2/models/{model}/generate_image
This would be the same as Option 3, but putting /generate_image
underneath the specific model instead of above it.
This has most of the same advantages/disadvantages as Option 3, so I’ll compare against that version here:
Advantages compared to Option 3
Conceptually, this represents the image model as a resource that has different methods available on it. For example, if the
acme_image
model supports image infilling or image modification, you can add/v2/models/acme_image/infill
or/v2/models/acme_image/modify
as endpoints, which is a natural representation of the fact that one model has different functionality, as opposed to one functionality only being supported by some models.On the backend side, it may be more natural to group your code by image model, rather than by which functionality of that model is being used. For example, you could split all of the image models that have shared API keys and SDK usage into separate files, like a file for both
acme_image
andacme_image_plus
that loadsACMEIMAGE_API_KEY
and theAcmeImage
library and has the handlers for/v2/models/acme_image_*/*
, and a separate file forzorg_6.0_plus_plus
that loads the Zorg API key and library with the handlers for/v2/models/zorg_*/*
. Depending on the specifics of your backend API, it may be more likely to have more shared code between the differentacme_*
models’ image operations than between thegenerate_image
methods for different models.For users of your SDK, there may also be some advantages to having the image model be an object that can be assigned to a variable or passed as a function argument:
This may be easier or harder in some target languages (passing image model as a function argument requires some amount of polymorphism or custom code to define an interface). However, if this works, it can be an easy way for users of your SDKs to define what image model they’re using in a single place (without having to retype the model name at many different call sites) and quickly swap it out for a different model if they want to.
Disadvantages compared to Option 3
This approach is more verbose than Option 3, which is somewhat off-putting:
It’s not strictly necessary to use
models
as a separate resource (you could doclient.acme_image.generate_image(...)
) but it’s a good idea to useclient.models.*
in case you have a model whose name is not self-explanatory or if an image model’s name overlaps with something else on your client.Users of your SDK will need to type the name of the model before they get typeahead suggestions for which operations that model has available. This is somewhat backwards compared to “I want to do text-to-image, which models can do that?”, which is what will happen if the user types
client.generate_image.
and gets autocomplete suggestions.
Option 4A: add a default model endpoint
One of the drawbacks to options 3 and 4 is that they require users to make a conscious choice about which model they want to use, when many users just want a sensible default. One option for solving this problem is to add an extra endpoint which uses whichever model you choose internally as a default (which can be swapped out for a different model later).
For configuration options, it would be good for this endpoint to only provide simplified or lowest-common-denominator parameters that are common to all models, which would allow you to swap out which model is used as the default in the future. For example, instead of passing exact pixel dimensions (which are very model-specific), you could have a more approximate small/medium/large parameter, which can be mapped to whichever pixel dimensions are supported by the current default model. This pairs nicely with Option 4, because the generated client code has a separate place for the simple default generate_image()
method and the place where you can find different generation models:
In your stainless config, this would be defined as a client method:
This modification is not as easily compatible with Option 3, because the generated SDK code does not support client.generate_image
being both a callable function and something that has different models as subfields (and this would be bad practice in most languages). You could use the endpoint URLs from Option 3 and map them in your Stainless configuration to resources and methods for client.generate_image()
and client.models.*.generate_image()
, but this would cause a mismatch between the REST API model and the SDK object model, which is best to avoid when possible.
Recommendations
Options 2 and 4A are both reasonable choices. Deciding between them depends on what types of changes are easier to make on your backend, as well as how you anticipate your API growing over time. Option 1 is probably not the right choice unless you expect your models to almost always have nearly the exact same options, you don’t want to make any breaking changes to the API, and you’re okay with ignoring invalid options or making the client handle errors at runtime.
If you wanted to make an API that has fewer, more concise methods and sensible default options for users who don’t care about which model is used, then Option 2 or Option 4A is probably the right choice. Options 2 and 4A both have a great way to provide a default image generation model which is much more awkward for Option 3 or Option 4 without the default endpoint.
On the other hand, if you expect to have many different endpoints available that are only selectively supported by some models, then Options 3 or 4 might be preferable. For example, if you expected acme_image
and acme_image_plus
to have new endpoints like /infill
in the future, but you expect zorg_6.0_plus_plus
will not have infill. In that case, you would lose some of the benefits of Option 2’s ability to pass the same image model configuration argument to different image generation endpoints, so Options 3 or 4 might be a better choice.
Between options 3 and 4, the decision would depend mainly on whether you like the benefits of conceptually representing an image model as a group of functionality that can be passed around as an object (Option 4), or if you prefer orienting your API around choosing the functionality first and narrowing down to which model afterwards (Option 3). Option 4A with the default model endpoint is also a strong contender for most user-friendly solution.
Originally posted
Sep 19, 2025