Skip to content

feat(models): add configurable model ID format for GET /v1/models#333

Open
vfeitoza wants to merge 1 commit into
ENTERPILOT:mainfrom
vfeitoza:feat/models-endpoint-id-format
Open

feat(models): add configurable model ID format for GET /v1/models#333
vfeitoza wants to merge 1 commit into
ENTERPILOT:mainfrom
vfeitoza:feat/models-endpoint-id-format

Conversation

@vfeitoza
Copy link
Copy Markdown
Contributor

@vfeitoza vfeitoza commented May 15, 2026

Problem:

The GET /v1/models endpoint returns model IDs in qualified format (provider/model, e.g. "anthropic/claude-sonnet-4-6"). OpenAI-compatible clients such as Claude CLI, Cursor, and other tools expect simple model IDs (e.g. "claude-sonnet-4-6"). When these clients fetch the model list to validate a requested model against available models, the qualified IDs never match the simple ID the client intends to send, resulting in "model not found" errors on the client side.

The internal routing already resolves both qualified and unqualified selectors correctly — the problem is exclusively in the public listing response format.

Solution:

Add a new configuration option models_endpoint_id_format (env: MODELS_ENDPOINT_ID_FORMAT) that controls the model ID format returned by GET /v1/models. Supported values:

  • "qualified" (default): provider/model format (current behavior, no breaking change)
  • "unqualified": simple model ID only, with OwnedBy set to the provider name for disambiguation
  • "both": returns both qualified and unqualified entries, allowing clients that understand either format to work seamlessly

Implementation:

  • config/models.go: ModelsEndpointIDFormat type with validation and normalization helpers following existing ConfiguredProviderModelsMode pattern
  • config/config.go: default value set to "qualified"
  • internal/providers/registry.go: ListModelsWithFormat() dispatches to ListPublicModels (qualified), listModelsUnqualified, or listModelsBoth. Unqualified deduplicates by model ID (first provider wins, matching routing behavior). Both merges qualified + unqualified with ID-based deduplication.
  • internal/providers/router.go: Router gains modelsEndpointIDFormat field and SetModelsEndpointIDFormat setter. ListModels uses the configured format via interface assertion on the lookup.
  • internal/app/app.go: wires config to Router after provider init
  • config/config.example.yaml, .env.template: documents the new option
  • internal/providers/router_test.go: TestRouterListModelsWithFormat covers all three format modes

Summary by CodeRabbit

  • New Features
    • Introduced configurable formatting for model IDs returned by the models endpoint. Users can now choose between "qualified" (including provider prefix), "unqualified" (model name only), or "both" formats, configurable via the MODELS_ENDPOINT_ID_FORMAT environment variable or configuration file.

Review Change Stack

Problem:

The GET /v1/models endpoint returns model IDs in qualified format
(provider/model, e.g. "anthropic/claude-sonnet-4-6"). OpenAI-compatible
clients such as Claude CLI, Cursor, and other tools expect simple model
IDs (e.g. "claude-sonnet-4-6"). When these clients fetch the model list
to validate a requested model against available models, the qualified IDs
never match the simple ID the client intends to send, resulting in
"model not found" errors on the client side.

The internal routing already resolves both qualified and unqualified
selectors correctly — the problem is exclusively in the public listing
response format.

Solution:

Add a new configuration option `models_endpoint_id_format` (env:
MODELS_ENDPOINT_ID_FORMAT) that controls the model ID format returned
by GET /v1/models. Supported values:

- "qualified" (default): provider/model format (current behavior,
  no breaking change)
- "unqualified": simple model ID only, with OwnedBy set to the
  provider name for disambiguation
- "both": returns both qualified and unqualified entries, allowing
  clients that understand either format to work seamlessly

Implementation:

- config/models.go: ModelsEndpointIDFormat type with validation and
  normalization helpers following existing ConfiguredProviderModelsMode
  pattern
- config/config.go: default value set to "qualified"
- internal/providers/registry.go: ListModelsWithFormat() dispatches to
  ListPublicModels (qualified), listModelsUnqualified, or listModelsBoth.
  Unqualified deduplicates by model ID (first provider wins, matching
  routing behavior). Both merges qualified + unqualified with ID-based
  deduplication.
- internal/providers/router.go: Router gains modelsEndpointIDFormat field
  and SetModelsEndpointIDFormat setter. ListModels uses the configured
  format via interface assertion on the lookup.
- internal/app/app.go: wires config to Router after provider init
- config/config.example.yaml, .env.template: documents the new option
- internal/providers/router_test.go: TestRouterListModelsWithFormat
  covers all three format modes
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This PR adds configurable model ID formatting to the GET /v1/models endpoint. Applications can now return qualified IDs (provider/model), unqualified IDs (model only), or both via the MODELS_ENDPOINT_ID_FORMAT configuration option, defaulting to qualified format.

Changes

Model ID Format Configuration

Layer / File(s) Summary
Configuration & Type Definitions
.env.template, config/config.example.yaml, config/config.go, config/models.go
Environment variable, example config, and Go type system define the new MODELS_ENDPOINT_ID_FORMAT option with constants qualified, unqualified, both and validation/normalization helpers.
Registry Listing Implementation
internal/providers/registry.go
ListModelsWithFormat method added with internal helpers: listModelsUnqualified deduplicates unqualified IDs across providers; listModelsBoth merges qualified and unqualified pairs while sorting results.
Router Integration & App Wiring
internal/providers/router.go, internal/app/app.go
Router struct stores format config, SetModelsEndpointIDFormat allows runtime adjustment, ListModels delegates to ListModelsWithFormat when available, and app initialization wires the config to the router.
Test Coverage
internal/providers/router_test.go
TestRouterListModelsWithFormat validates that setting different formats produces expected model ID representations (qualified with /, unqualified without /, both with both types).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#160: Both PRs modify model-listing behavior in registry and router; this PR adds configurable qualified/unqualified/both formatting via ListModelsWithFormat, while that PR introduced prefixed ListPublicModels.

Poem

🐰 A format for models, so neat and so fine,
Qualified, unqualified—pick which design!
Config decides if the slashes stay true,
The registry formats, the router threads through.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a configurable model ID format option for the GET /v1/models endpoint. It is specific, concise, and directly reflects the primary objective of the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 15, 2026

Greptile Summary

This PR adds a models_endpoint_id_format config option (env: MODELS_ENDPOINT_ID_FORMAT) that controls whether GET /v1/models returns qualified (provider/model), unqualified (model), or both formats — defaulting to qualified for backwards compatibility.

  • Config layer (config/models.go, config/config.go): new ModelsEndpointIDFormat type with Valid/Normalize/Resolve helpers, mirroring the existing ConfiguredProviderModelsMode pattern.
  • Registry (internal/providers/registry.go): adds ListModelsWithFormat, listModelsUnqualified, and listModelsBoth; listModelsUnqualified iterates r.modelsByProvider for deduplication, but since Go map iteration is non-deterministic, the "first provider wins" guarantee stated in the PR description does not hold — r.models (deterministic, registration-order) should be used instead.
  • Router / wiring (internal/providers/router.go, internal/app/app.go): ListModels now dispatches through a new interface assertion before falling back to publicModelLister, and the config value is wired in at startup.

Confidence Score: 3/5

Safe to merge only after fixing the non-deterministic deduplication in listModelsUnqualified — the default qualified format is unaffected, but operators using unqualified or both modes will get inconsistent OwnedBy values across restarts.

The new unqualified and both modes contain a real logic defect: listModelsUnqualified iterates r.modelsByProvider (a Go map) to deduplicate shared model IDs, so which provider wins and its associated OwnedBy value is random on every cold-path call. The routing layer uses r.models (deterministic, first-registered order), so the listing can claim a different owning provider than the one that would actually handle the request. The qualified default path is unaffected, keeping existing deployments safe.

internal/providers/registry.go — specifically the listModelsUnqualified function and the dropped ModelCount doc comment.

Important Files Changed

Filename Overview
internal/providers/registry.go Adds ListModelsWithFormat, listModelsUnqualified, and listModelsBoth. The unqualified deduplication iterates a Go map non-deterministically instead of using r.models (which is deterministic first-registered). The ModelCount doc comment was also accidentally dropped.
internal/providers/router.go Adds modelsEndpointIDFormat field and SetModelsEndpointIDFormat setter; ListModels now dispatches through the new interface assertion before falling back to publicModelLister. Logic looks correct.
config/models.go Adds ModelsEndpointIDFormat type, constants, Valid/Normalize/Resolve helpers — closely follows the existing ConfiguredProviderModelsMode pattern. No issues.
internal/providers/router_test.go TestRouterListModelsWithFormat covers qualified, unqualified, and both modes. Tests don't cover OwnedBy values or duplicate-model-ID deduplication, which would expose the non-determinism bug in listModelsUnqualified.
config/config.go Adds ModelsEndpointIDFormatQualified as the default — correct and no breaking change.
internal/app/app.go Wires config value to Router.SetModelsEndpointIDFormat after provider init — straightforward and correct.
.env.template Adds MODELS_ENDPOINT_ID_FORMAT env var documentation alongside existing CONFIGURED_PROVIDER_MODELS_MODE.
config/config.example.yaml Adds models_endpoint_id_format with inline comment documenting supported values and the env var name.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["GET /v1/models"] --> B["Router.ListModels()"]
    B --> C{lookup implements\nListModelsWithFormat?}
    C -- yes --> D["ListModelsWithFormat(format)"]
    C -- no --> E{lookup implements\nListPublicModels?}
    E -- yes --> F["ListPublicModels()\n(qualified, legacy)"]
    E -- no --> G["ListModels()\n(fallback)"]
    D --> H{format}
    H -- qualified --> I["ListPublicModels()\nprovider/model"]
    H -- unqualified --> J["listModelsUnqualified()\nmodel only"]
    H -- both --> K["listModelsBoth()\nqualified + unqualified"]
    J --> L["⚠ dedup via modelsByProvider\n(non-deterministic map iteration)"]
    K --> M["merge qualified list\nthen unqualified dedup"]
Loading

Reviews (1): Last reviewed commit: "feat(models): add configurable model ID ..." | Re-trigger Greptile

Comment on lines +361 to +379
r.mu.RLock()
defer r.mu.RUnlock()

seen := make(map[string]struct{})
result := make([]core.Model, 0, len(r.models))
for providerName, models := range r.modelsByProvider {
for modelID, info := range models {
if _, exists := seen[modelID]; exists {
continue
}
seen[modelID] = struct{}{}
model := info.Model
model.ID = modelID
model.OwnedBy = providerName
result = append(result, model)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Non-deterministic deduplication breaks "first provider wins" guarantee

listModelsUnqualified iterates over r.modelsByProvider, a Go map whose iteration order is randomized per run. When two providers share the same model ID (e.g., gpt-4o on both openai and openrouter), which provider "wins" the deduplication — and therefore what OwnedBy and model metadata are returned — is random. The PR description says this matches routing behavior, but routing uses r.models (populated in registration order, first-registered wins deterministically) as the source of truth. Using r.models here instead would give the correct, stable result: the same provider that routing would actually use.

Comment on lines +398 to 401
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (r *ModelRegistry) ModelCount() int {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing blank line and doc comment between listModelsBoth and ModelCount. The original // ModelCount returns the number of registered models comment was dropped when the new functions were inserted.

Suggested change
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (r *ModelRegistry) ModelCount() int {
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
// ModelCount returns the number of registered models
func (r *ModelRegistry) ModelCount() int {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 71.42857% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
config/models.go 0.00% 13 Missing ⚠️
internal/providers/registry.go 89.74% 2 Missing and 2 partials ⚠️
internal/app/app.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/config.go`:
- Line 65: Add validation and normalization for ModelsEndpointIDFormat inside
the Load() function similar to how ConfiguredProviderModelsMode is handled:
after resolving the field, check that the value is one of the allowed constants
(e.g., ModelsEndpointIDFormatQualified and any other valid enum values),
normalize any synonyms if needed, and return an error if the value is invalid
instead of silently defaulting; update references to ModelsEndpointIDFormat and
ensure the defaulting code (which currently sets ModelsEndpointIDFormat:
ModelsEndpointIDFormatQualified) does not hide invalid user input by performing
this validation/normalization in Load().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 833c32cd-18d6-4afc-952a-59421486093b

📥 Commits

Reviewing files that changed from the base of the PR and between ddd80ae and 61630f4.

📒 Files selected for processing (8)
  • .env.template
  • config/config.example.yaml
  • config/config.go
  • config/models.go
  • internal/app/app.go
  • internal/providers/registry.go
  • internal/providers/router.go
  • internal/providers/router_test.go

Comment thread config/config.go
OverridesEnabled: true,
KeepOnlyAliasesAtModelsEndpoint: false,
ConfiguredProviderModelsMode: ConfiguredProviderModelsModeFallback,
ModelsEndpointIDFormat: ModelsEndpointIDFormatQualified,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add validation for ModelsEndpointIDFormat in Load().

ConfiguredProviderModelsMode is both resolved and validated in Load() (lines 169–172), but ModelsEndpointIDFormat is only defaulted here with no corresponding normalization or validation in Load(). This inconsistency means invalid user-supplied values will silently fall back to the default instead of producing a clear configuration error.

Proposed fix to add validation after line 172
 cfg.Models.ConfiguredProviderModelsMode = ResolveConfiguredProviderModelsMode(cfg.Models.ConfiguredProviderModelsMode)
 if !cfg.Models.ConfiguredProviderModelsMode.Valid() {
 	return nil, fmt.Errorf("models.configured_provider_models_mode must be one of: fallback, allowlist")
 }
+cfg.Models.ModelsEndpointIDFormat = ResolveModelsEndpointIDFormat(cfg.Models.ModelsEndpointIDFormat)
+if !cfg.Models.ModelsEndpointIDFormat.Valid() {
+	return nil, fmt.Errorf("models.models_endpoint_id_format must be one of: qualified, unqualified, both")
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/config.go` at line 65, Add validation and normalization for
ModelsEndpointIDFormat inside the Load() function similar to how
ConfiguredProviderModelsMode is handled: after resolving the field, check that
the value is one of the allowed constants (e.g., ModelsEndpointIDFormatQualified
and any other valid enum values), normalize any synonyms if needed, and return
an error if the value is invalid instead of silently defaulting; update
references to ModelsEndpointIDFormat and ensure the defaulting code (which
currently sets ModelsEndpointIDFormat: ModelsEndpointIDFormatQualified) does not
hide invalid user input by performing this validation/normalization in Load().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants