Skip to content

implementation(PR-98): model aliases#99

Merged
thushan merged 11 commits intothushan:mainfrom
dnnspaul:main
Mar 30, 2026
Merged

implementation(PR-98): model aliases#99
thushan merged 11 commits intothushan:mainfrom
dnnspaul:main

Conversation

@dnnspaul
Copy link
Copy Markdown
Contributor

@dnnspaul dnnspaul commented Feb 8, 2026

Feature: Model Aliases

Related Issue

Closes #98

What This Does

Hey @thushan 👋

This PR implements the model alias feature requested in #98. When using multiple backends (Ollama, LM Studio, llamacpp, etc.), the same model often has different names across platforms. Model aliases let users define a single virtual name that maps to all the platform-specific variants, so any backend that has the model can serve the request — regardless of naming differences.

Example Configuration

In config.yaml:

model_aliases:
  gpt-oss-120b:
    - gpt-oss:120b              # Ollama format
    - gpt-oss-120b-MLX          # LM Studio MLX format
    - gguf_gpt_oss_120b.gguf    # llamacpp GGUF filename

When a request arrives with "model": "gpt-oss-120b", olla will:

  1. Look up which endpoints serve any of the listed actual model names
  2. Route to a healthy endpoint (respecting load balancing, priority, etc.)
  3. Rewrite the "model" field in the request body to match what the selected backend actually expects (e.g. gpt-oss:120b for Ollama)

Self-referencing aliases work correctly

If the alias name is also a real model name on some backend, it still works:

gpt-oss-120b:
  - gpt-oss:120b
  - gpt-oss-120b     # ← same name as the alias, but it's a real model on LM Studio

The alias always takes priority during routing. The self-referencing entry ensures the endpoint serving gpt-oss-120b natively is included in the candidate pool, and the request body keeps gpt-oss-120b for that backend (no unnecessary rewrite).

What's NOT included

Per the discussion in the issue, the only_alias: true setting (to hide non-aliased models from /v1/models) is not part of this PR. Happy to add it as a follow-up if desired.

How It Works (Architecture)

The implementation follows olla's hexagonal architecture and touches minimal surface area:

Component What it does
Config.ModelAliases New map[string][]string field in config, parsed from model_aliases in YAML
AliasResolver Resolves alias → actual model names, queries the model registry for endpoints, builds an endpoint→model rewrite map
handler_proxy.go Checks for aliases in the model routing pipeline (stage 3 of filterEndpointsByProfile), passes the rewrite map through context
core.RewriteModelForAlias() Shared utility called by both Olla and Sherpa proxy engines — rewrites the "model" field in the JSON body right before the request is sent to the selected backend

The alias resolution hooks into the existing model routing pipeline without changing the ModelRegistry interface or any existing routing strategies.

Files Changed

New files:

  • internal/adapter/registry/alias.go — AliasResolver
  • internal/adapter/registry/alias_test.go — Tests
  • internal/adapter/proxy/core/model_rewrite.go — Request body model rewriting
  • internal/adapter/proxy/core/model_rewrite_test.go — Tests

Modified files:

  • internal/config/types.go — Added ModelAliases field to Config
  • internal/config/config_test.go — Config parsing tests for aliases
  • internal/core/constants/context.go — Context key for alias rewrite map
  • internal/app/handlers/application.go — Wires up the AliasResolver
  • internal/app/handlers/handler_proxy.go — Alias-aware endpoint filtering + context passing
  • internal/adapter/proxy/olla/service.go — Model rewrite call (legacy path)
  • internal/adapter/proxy/olla/service_retry.go — Model rewrite call (retry path)
  • internal/adapter/proxy/sherpa/service_retry.go — Model rewrite call
  • config/config.yaml — Documented example (commented out)
  • config.yaml — Same

Testing

  • All existing tests pass (go test ./internal/...)
  • New tests cover:
    • Alias resolver: creation, lookup, endpoint resolution, self-referencing aliases, edge cases
    • Model rewrite: JSON body rewriting, content-length updates, no-op for non-JSON/missing model/no alias
    • Config parsing: YAML deserialization of model_aliases
  • Build passes cleanly (go build ./..., go vet ./internal/...)

Summary by CodeRabbit

  • New Features

    • Model aliases: virtual model names can map to multiple real models; alias resolution selects matching endpoints and rewrites the request's top-level model field before proxying.
  • Configuration

    • New top-level config key model_aliases to declare alias → actual model lists with priority rules and validation.
  • Documentation

    • New guide and updated reference with examples, priority rules and troubleshooting.
  • Tests

    • Extensive tests covering alias resolution, rewrite behaviour, content-length and edge cases.

@dnnspaul dnnspaul requested a review from thushan as a code owner February 8, 2026 15:53
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds model-alias support: configuration and docs, a new AliasResolver that maps aliases to actual models and discovers endpoints, propagation of endpoint→model maps in request context, and request-body rewriting of the top-level "model" field before proxying to backend endpoints.

Changes

Cohort / File(s) Summary
Configuration & docs
config/config.yaml, docs/content/concepts/model-aliases.md, docs/content/configuration/reference.md, docs/mkdocs.yml
Adds model_aliases config docs, examples, reference entry and navigation.
Config types & tests
internal/config/types.go, internal/config/config_test.go
Adds ModelAliases map[string][]string to Config and validation logic; extensive unit tests for parsing and validation.
Constants
internal/core/constants/context.go
Adds exported constant ContextModelAliasMapKey for storing endpoint→model maps in context.
Alias resolution (registry)
internal/adapter/registry/alias.go, internal/adapter/registry/alias_test.go
New AliasResolver (constructor, IsAlias, GetActualModels, ResolveEndpoints) that resolves alias→actual-model and builds endpoint→model map; tests cover resolution, first-wins policy and edge cases.
Request body rewrite core
internal/adapter/proxy/core/model_rewrite.go, internal/adapter/proxy/core/model_rewrite_test.go
New RewriteModelForAlias(ctx, r, endpoint) rewrites top-level JSON model field when a context-provided alias map maps the endpoint to an actual model; preserves formatting and updates ContentLength; comprehensive tests for edge cases.
Proxy integrations
internal/adapter/proxy/olla/service.go, internal/adapter/proxy/olla/service_retry.go, internal/adapter/proxy/sherpa/service_retry.go
Call core.RewriteModelForAlias before constructing downstream proxy requests for alias-resolved endpoints.
Handler / application layer
internal/app/handlers/application.go, internal/app/handlers/handler_proxy.go, internal/app/handlers/handler_proxy_alias_test.go
Application holds aliasResolver when configured; handler detects aliases, resolves endpoints, stores endpoint→actual-model map in request/profile context, routes using resolved endpoints; tests added.
Startup
main.go
Validates model aliases at startup via cfg.ValidateModelAliases() and fails fast on invalid config.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Handler as Application/Handler
    participant Resolver as AliasResolver
    participant Proxy as ProxyService
    participant Rewriter as core.RewriteModelForAlias
    participant Backend as ModelEndpoint

    Client->>Handler: POST /v1/chat/completions (model: alias)
    Handler->>Resolver: ResolveEndpoints(alias)
    Resolver-->>Handler: map(endpointURL -> actualModel)
    Handler->>Proxy: forward request (with alias-map in context)
    Proxy->>Rewriter: RewriteModelForAlias(ctx, req, endpoint)
    rect rgba(200,230,255,0.5)
      Rewriter-->>Proxy: modified request body (model: actualModel)
    end
    Proxy->>Backend: proxied request with actualModel
    Backend-->>Proxy: response
    Proxy-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement, routing

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.77% 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 PR title 'implementation(PR-98): model aliases' clearly and concisely summarises the main change—implementing model alias functionality for issue #98.
Linked Issues check ✅ Passed The PR successfully implements all primary coding objectives from issue #98: configuration mechanism for model aliases, endpoint routing, request body rewriting with proper escaping, support for self-referencing aliases, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the model aliases feature; no unrelated modifications to other features or components are present.

✏️ 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.

Copy link
Copy Markdown

@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

🤖 Fix all issues with AI agents
In `@internal/adapter/proxy/core/model_rewrite_test.go`:
- Around line 247-267: The test TestRewriteModelForAlias_BodyRereadable
overpromises by only reading the request body once after calling
RewriteModelForAlias; either rename the test to indicate it only verifies the
body is readable after rewrite (e.g.,
TestRewriteModelForAlias_BodyReadableAfterRewrite) or actually perform a second
read by resetting the body position (use the bytes.Reader created in
RewriteModelForAlias via io.NopCloser(bytes.NewReader(...)) — obtain a new
bytes.Reader or type-assert to enable Seek or re-wrap the same bytes into a new
io.NopCloser before the second read) so the test truly verifies multiple reads.
Ensure you update the test name or add the second read and assertions
accordingly while referencing TestRewriteModelForAlias_BodyRereadable and
RewriteModelForAlias.
🧹 Nitpick comments (4)
internal/config/types.go (1)

24-35: Consider adding validation for ModelAliases entries.

Currently there's no config-level validation — an alias with an empty model list (e.g., gpt-oss-120b: []) or an empty alias key would silently pass through. A Validate() method on Config (or within the existing validation flow) could catch misconfigurations early and provide clear error messages at startup.

internal/app/handlers/handler_proxy.go (1)

406-455: Alias resolution routing logic looks correct and well-structured.

The fallback to standard routing when alias resolution yields no endpoints (lines 415-429) is a sensible resilience pattern. The method correctly filters candidates against resolved endpoints and stores the rewrite map for downstream use. Comments use appropriate Australian English ("recognises").

One minor note: on line 416, when err is nil but endpointToModel is empty, the log message includes "error", err which will emit "error", <nil>. This is cosmetic but slightly noisy.

Optional: omit error from log when nil
-	if err != nil || len(endpointToModel) == 0 {
-		logger.Warn("Model alias resolved to no endpoints, falling back to standard routing",
-			"alias", aliasName,
-			"error", err)
+	if err != nil || len(endpointToModel) == 0 {
+		logFields := []any{"alias", aliasName}
+		if err != nil {
+			logFields = append(logFields, "error", err)
+		}
+		logger.Warn("Model alias resolved to no endpoints, falling back to standard routing", logFields...)
internal/adapter/proxy/core/model_rewrite.go (1)

56-83: JSON re-serialisation reorders keys and strips formatting.

rewriteModelField unmarshals into map[string]json.RawMessage and re-marshals, which produces compact JSON with keys in an undefined order. This is functionally correct for the model field rewrite, but it does alter the byte-level representation of the entire request body. If any downstream component or backend is sensitive to key ordering or whitespace, this could cause subtle issues.

This is likely acceptable given that all consumers are JSON parsers, but worth noting.

internal/adapter/registry/alias.go (1)

13-19: Remove the unused reverseIndex field from AliasResolver.

The reverseIndex field is populated in the constructor but never read by any method in the type. None of the methods (IsAlias, GetActualModels, ResolveEndpoints) reference it—they all use only the aliases map. Removing this field will reduce the struct size and eliminate dead code that serves no purpose.

Comment on lines +247 to +267
// Ensure the http.Request's Body can be read multiple times after rewrite
func TestRewriteModelForAlias_BodyRereadable(t *testing.T) {
aliasMap := map[string]string{
"http://ollama:11434": "rewritten-model",
}
ctx := context.WithValue(context.Background(), constants.ContextModelAliasMapKey, aliasMap)
body := `{"model": "original-model"}`
r, _ := http.NewRequestWithContext(ctx, "POST", "/v1/chat/completions", bytes.NewBufferString(body))
r.ContentLength = int64(len(body))

endpoint := &domain.Endpoint{URLString: "http://ollama:11434"}
RewriteModelForAlias(ctx, r, endpoint)

// Read body first time
body1, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("first read failed: %v", err)
}
if len(body1) == 0 {
t.Error("first read should return non-empty body")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Test name overpromises — body is only read once.

TestRewriteModelForAlias_BodyRereadable claims to verify the body can be read multiple times, but it only performs a single read. Since RewriteModelForAlias sets the body to io.NopCloser(bytes.NewReader(...)), a bytes.Reader does support Seek, but it's not exposed through the io.ReadCloser interface. Either rename the test to reflect what it actually checks (e.g., _BodyReadableAfterRewrite) or add a second read after seeking.

🤖 Prompt for AI Agents
In `@internal/adapter/proxy/core/model_rewrite_test.go` around lines 247 - 267,
The test TestRewriteModelForAlias_BodyRereadable overpromises by only reading
the request body once after calling RewriteModelForAlias; either rename the test
to indicate it only verifies the body is readable after rewrite (e.g.,
TestRewriteModelForAlias_BodyReadableAfterRewrite) or actually perform a second
read by resetting the body position (use the bytes.Reader created in
RewriteModelForAlias via io.NopCloser(bytes.NewReader(...)) — obtain a new
bytes.Reader or type-assert to enable Seek or re-wrap the same bytes into a new
io.NopCloser before the second read) so the test truly verifies multiple reads.
Ensure you update the test name or add the second read and assertions
accordingly while referencing TestRewriteModelForAlias_BodyRereadable and
RewriteModelForAlias.

- Rename BodyRereadable test to BodyReadableAfterRewrite (test only
reads once)
- Remove unused reverseIndex field from AliasResolver
- Omit nil error from log when alias resolves to no endpoints
Copy link
Copy Markdown

@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: 2

🤖 Fix all issues with AI agents
In `@internal/app/handlers/handler_proxy.go`:
- Around line 406-458: The alias-success path in resolveAliasEndpoints doesn't
set profile.RoutingDecision, so downstream code (executeProxyRequest,
logRequestResult) lacks routing metadata; before returning aliasEndpoints,
synthesize and assign a lightweight domain.RoutingDecision to
profile.RoutingDecision (e.g., populate the selected endpoint/URL, a strategy or
reason like "alias", and any minimal fields used by logging/metrics), reusing
domain.RoutingDecision type and keys (and keep
profile.SetInspectionMeta(endpointToModel) as-is) so observability consumers
receive consistent routing info for alias-routed requests.
- Around line 435-440: The lookup inconsistency can cause silent mismatches:
update all map key uses and comparisons to consistently use the getter rather
than direct field access—replace any direct accesses to endpoint.URLString with
endpoint.GetURLString() (including where endpointToModel is populated and where
it's queried, e.g., the aliasEndpoints loop and the other occurrence around the
earlier reported URLString use) so both map keys and lookups use the same
normalized value provided by GetURLString().
🧹 Nitpick comments (1)
internal/adapter/proxy/core/model_rewrite_test.go (1)

154-199: Consider adding a table case where the new model name contains characters that need JSON escaping.

Model names with characters like " or \ would exercise whether rewriteModelField produces valid JSON after substitution. Not blocking, just a hardening suggestion.

Comment on lines +435 to +440
var aliasEndpoints []*domain.Endpoint
for _, endpoint := range candidates {
if _, ok := endpointToModel[endpoint.GetURLString()]; ok {
aliasEndpoints = append(aliasEndpoints, endpoint)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if GetURLString() simply returns URLString or does something else
ast-grep --pattern $'func ($_ *Endpoint) GetURLString() string {
  $$$
}'

Repository: thushan/olla

Length of output: 228


🏁 Script executed:

#!/bin/bash
# Find ResolveEndpoints function and how endpointToModel is built
rg -A 30 "ResolveEndpoints" internal/app/handlers/handler_proxy.go | head -80

Repository: thushan/olla

Length of output: 1170


🏁 Script executed:

#!/bin/bash
# Find the ResolveEndpoints method implementation
rg -B 5 "func.*ResolveEndpoints.*{" --type go | head -50

Repository: thushan/olla

Length of output: 169


🏁 Script executed:

#!/bin/bash
# Find ResolveEndpoints implementation
rg "func.*ResolveEndpoints" --type go -A 20 | head -100

Repository: thushan/olla

Length of output: 5997


🏁 Script executed:

#!/bin/bash
# Get full ResolveEndpoints implementation
sed -n '1,500p' internal/adapter/registry/alias.go | grep -A 50 "func.*ResolveEndpoints"

Repository: thushan/olla

Length of output: 1209


🏁 Script executed:

#!/bin/bash
# Find GetEndpointsForModel implementation
rg "func.*GetEndpointsForModel" --type go -A 15

Repository: thushan/olla

Length of output: 15046


🏁 Script executed:

#!/bin/bash
# Get more context from MemoryModelRegistry
rg -A 30 "func.*MemoryModelRegistry.*GetEndpointsForModel" internal/adapter/registry/memory_registry.go

Repository: thushan/olla

Length of output: 890


🏁 Script executed:

#!/bin/bash
# Find RegisterModel implementation
rg -A 40 "func.*MemoryModelRegistry.*RegisterModel" internal/adapter/registry/memory_registry.go | head -60

Repository: thushan/olla

Length of output: 2041


🏁 Script executed:

#!/bin/bash
# Get full RegisterModel function
sed -n '/func.*MemoryModelRegistry.*RegisterModel/,/^func/p' internal/adapter/registry/memory_registry.go | head -100

Repository: thushan/olla

Length of output: 1859


🏁 Script executed:

#!/bin/bash
# Find calls to RegisterModel that might use endpoint.URLString or GetURLString
rg "RegisterModel.*endpoint\." --type go -B 2 -A 2 | head -50

Repository: thushan/olla

Length of output: 919


🏁 Script executed:

#!/bin/bash
# Check line 566 and surrounding context in handler_proxy.go
sed -n '560,575p' internal/app/handlers/handler_proxy.go

Repository: thushan/olla

Length of output: 550


🏁 Script executed:

#!/bin/bash
# Find Endpoint struct definition
rg -A 20 "type Endpoint struct" internal/core/domain/endpoint.go

Repository: thushan/olla

Length of output: 739


🏁 Script executed:

#!/bin/bash
# Find where URLString is assigned
rg "URLString\s*=" internal/core/domain/endpoint.go -B 2 -A 1

Repository: thushan/olla

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Search for Endpoint construction
rg "URLString\s*=" --type go -B 2 -A 1 | grep -A 3 -B 3 "URLString"

Repository: thushan/olla

Length of output: 2248


🏁 Script executed:

#!/bin/bash
# Search for Endpoint construction
rg "&domain.Endpoint\{" --type go -A 10 | head -100

Repository: thushan/olla

Length of output: 6932


🏁 Script executed:

#!/bin/bash
# Search for modifications to endpoint.URL
rg "endpoint\.URL\s*=" --type go -B 2 -A 1

Repository: thushan/olla

Length of output: 459


Standardise URL lookups to prevent silent endpoint mismatches.

The code currently uses both endpoint.GetURLString() and endpoint.URLString inconsistently—some places call the getter method (line 437 here) whilst others access the field directly (line 566). Whilst GetURLString() presently returns the field unchanged, this pattern is fragile. If the getter is later modified to apply normalisation (e.g., trailing slash handling, scheme normalisation), the lookup into endpointToModel would silently miss endpoints. Ensure consistent use of one approach throughout the codebase, preferably via the getter method for maintainability.

🤖 Prompt for AI Agents
In `@internal/app/handlers/handler_proxy.go` around lines 435 - 440, The lookup
inconsistency can cause silent mismatches: update all map key uses and
comparisons to consistently use the getter rather than direct field
access—replace any direct accesses to endpoint.URLString with
endpoint.GetURLString() (including where endpointToModel is populated and where
it's queried, e.g., the aliasEndpoints loop and the other occurrence around the
earlier reported URLString use) so both map keys and lookups use the same
normalized value provided by GetURLString().

- Set RoutingDecision on alias happy path for response headers/metrics
- Standardise URL lookups: endpoint.URLString → endpoint.GetURLString()
Copy link
Copy Markdown

@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: 2

🤖 Fix all issues with AI agents
In `@docs/content/concepts/model-aliases.md`:
- Around line 193-198: Replace the inaccurate troubleshooting bullet "Endpoint
URL in the alias mapping doesn't match the discovered endpoint" with wording
that reflects alias → model-name mapping (e.g., "The actual model name in the
alias list doesn't exactly match the model name reported by the backend") so
readers understand aliases map names to models rather than endpoint URLs; update
the sentence in the model-aliases.md section where that bullet appears.
- Around line 40-65: The fenced ASCII-art block starting with the line
containing Client request: "model": "my-llama" should include a language
specifier to satisfy markdownlint MD040; update the opening triple-backtick that
begins that diagram to ```text so the block becomes a text-fenced code block
(leave the diagram content unchanged and only modify the opening fence).
🧹 Nitpick comments (1)
internal/app/handlers/handler_proxy.go (1)

362-365: Comment describes "what" rather than "why".

The coding guidelines ask comments to explain why rather than what. Consider something like: // aliases need separate resolution because they map to multiple actual model names across backends.

As per coding guidelines, **/*.go: "Comment on why rather than what in code".

✏️ Suggested comment rewording
-		// check for model alias before standard routing
+		// aliases map one name to multiple backend-specific models, so they
+		// need dedicated resolution rather than the standard single-model path
 		if a.aliasResolver != nil && a.aliasResolver.IsAlias(profile.ModelName) {

- Add language specifier to ASCII-art code block (```text)
- Fix inaccurate troubleshooting cause: aliases map model names, not
endpoint URLs
Copy link
Copy Markdown

@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: 2

🤖 Fix all issues with AI agents
In `@docs/content/concepts/model-aliases.md`:
- Around line 196-197: Update the first bullet text in the list that currently
reads "Actual model name in the alias list doesn't exactly match the model name
reported by the backend" to include the missing article at the start so it reads
"The actual model name in the alias list doesn’t exactly match the model name
reported by the backend"; locate and edit the exact bullet string in
docs/content/concepts/model-aliases.md (the list entry "Actual model name in the
alias list doesn't exactly match the model name reported by the backend") and
replace it with the corrected sentence.
- Around line 32-34: Replace the phrase "the exact same model" with the more
concise "the same model" in the paragraph that reads "Without aliases, a client
request for `llama3.1:8b` would only match the Ollama endpoint — even though the
other backends have the exact same model." Update that sentence so it reads
"...even though the other backends have the same model," preserving the
surrounding wording and inline code `llama3.1:8b`.

Comment on lines +196 to +197
- Actual model name in the alias list doesn't exactly match the model name reported by the backend
- Request body is not JSON
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add missing article for readability.

Suggested: “The actual model name in the alias list doesn’t exactly match the model name reported by the backend.”

🧰 Tools
🪛 LanguageTool

[uncategorized] ~196-~196: Possible missing article found.
Context: ...al model name. Possible Causes: - Actual model name in the alias list doesn't ex...

(AI_HYDRA_LEO_MISSING_THE)

🤖 Prompt for AI Agents
In `@docs/content/concepts/model-aliases.md` around lines 196 - 197, Update the
first bullet text in the list that currently reads "Actual model name in the
alias list doesn't exactly match the model name reported by the backend" to
include the missing article at the start so it reads "The actual model name in
the alias list doesn’t exactly match the model name reported by the backend";
locate and edit the exact bullet string in
docs/content/concepts/model-aliases.md (the list entry "Actual model name in the
alias list doesn't exactly match the model name reported by the backend") and
replace it with the corrected sentence.

@dnnspaul
Copy link
Copy Markdown
Contributor Author

dnnspaul commented Feb 9, 2026

Will it at some point stop complaining? 😁

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 9, 2026

Great work on the PR, haven't had a chance for a full review yet (on holidays till tomorrow!).

To make it clean, thinking of putting these in the models.yaml might be better/cleaner, thoughts?

I'll review this tomorrow once I have a dev environment, thanks again, I'll make a fix for the CI in main you can rebase on tomorrow too (for betteralign).

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 11, 2026

@dnnspaul:

ah, if you run better align on your branch and get it to fix it for you, and push, you'll be fine.

Alternatively, there's the make ready target that does all the things for you :)

@dnnspaul
Copy link
Copy Markdown
Contributor Author

To make it clean, thinking of putting these in the models.yaml might be better/cleaner, thoughts?

I'm feeling like the models.yaml is somewhat of an internal mapping config; e.g. I don't use it and only have the config.yaml mapped outside of my docker container. Moving the logic into the models.yaml would mean that everybody that wants to use it needs to copy the existing one from https://github.com/thushan/olla/blob/main/config/models.yaml and adjust it to their specific needs, right? Otherwise they would lose all "default" settings, aren't they?

ah, if you run better align on your branch and get it to fix it for you, and push, you'll be fine.

Alternatively, there's the make ready target that does all the things for you :)

I'll have a look this evening and get back to you :)

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 12, 2026

I'm feeling like the models.yaml is somewhat of an internal mapping config; e.g. I don't use it and only have the config.yaml mapped outside of my docker container.

Apologies I misunderstood, I thought you were going to add default aliases to common models across HF etc (that we'd ship). This was a royal pain to keep up to date. I agree in that case because users will have their own mappings.

I've given this to our existing pool of test boxes to use and setup aliases, i think one of the folks will set this up tomorrow and we'll use them over the weekend as an initial test across multiple backends (and lots of models).

Great contribution, thanks.

@dnnspaul
Copy link
Copy Markdown
Contributor Author

Hey @thushan, sorry for the late reply. I did the betteralign recommendations. Tell me, once your test boxes were successful, how to proceed, thanks :)

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 13, 2026

No worries, nothing left to do on our part, we'll let the tests happen over the weekend and hopefully be ready for a release on Monday with some of the other PRs.

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 16, 2026

This is looking good, couple of suggestions:

  • can we add some test coverage to resolveAliasEndpoints, this is quite critical
  • some validation / warnings about invalid model alias configurations (maybe a ValidateModelAliases() method?

thinking something like:

  func (c *Config) ValidateModelAliases() error {
      if len(c.ModelAliases) == 0 {
          return nil  // no aliases setup, so we  don't do anyhting
      }

      for aliasName, actualModels := range c.ModelAliases {
          // Empty alias name
          if aliasName == "" {
              return fmt.Errorf("model alias name cannot be empty")
          }

          // whitespace in alias name (warning, not error)
          if strings.TrimSpace(aliasName) != aliasName {
              return fmt.Errorf("model alias %q contains leading/trailing whitespace", aliasName)
          }

          // empty model list
          if len(actualModels) == 0 {
              return fmt.Errorf("model alias %q has no actual models configured", aliasName)
          }

          // empty strinsg in model list
          seen := make(map[string]bool)
          for i, modelName := range actualModels {
              if modelName == "" {
                  return fmt.Errorf("model alias %q has empty model name at position %d", aliasName, i)
              }

              // duplicate model names (warning via logger, not a fatal error as such)
              if seen[modelName] {

              }
              seen[modelName] = true
          }
      }

      return nil
  }

that we can call at startup.

From my brief view (I will have a proper test tonight after this was raised), it feels like because we unmarshal and marshal again (to substitute the model name), the top level keys are reordered and whitespace is removed (so compact JSON). It would be semantically identical but not byte identical (ignoring model name change) to the original payload?

I say this because we logged requests and the JSONL's came in very different (so our internal validator rated this very different threshold wise vs 5-8% difference, which we expect with model name change).

…JSON rewrite

- Add ValidateModelAliases() to Config with startup validation in
main.go
- Add handler_proxy_alias_test.go with 6 tests for resolveAliasEndpoints
- Rewrite rewriteModelField to use regex substitution preserving JSON
formatting
- Add byte-identity and validation tests
@dnnspaul
Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @thushan! Went through your suggestions and tackled all three.

Tests for resolveAliasEndpoints

Added handler_proxy_alias_test.go with 6 tests — covers the happy path, no-match fallback, self-referencing aliases, healthy-only filtering, routing decision metadata, and an integration test that goes through filterEndpointsByProfile end-to-end.

ValidateModelAliases() at startup

New method on Config in types.go that catches the obvious misconfigs early: empty names, whitespace in alias names, empty model lists, blank entries in lists. For duplicates it just logs a warning rather than erroring out. Called in main.go right after config load so it fails fast. 8 tests in config_test.go.

Byte-identical JSON rewrite

Good catch on the key reordering issue. Swapped the unmarshal/marshal approach for a regex that only touches the "model" value and leaves everything else alone — whitespace, key order, formatting all stay exactly as they were. Still does a quick json.Unmarshal first to make sure we're actually looking at a JSON object with a "model" key before the regex runs. Added tests that assert byte-level identity on compact, pretty-printed, and mixed-order payloads.

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 18, 2026

Thanks, I noticed some failures with this new round when I tried this yesterday (mostly fuzz tests for model matching).

The regex might be the cause, let's see if the CodeRabbit hops onto it, I will dig it up otherwise.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 18, 2026

🧩 Analysis chain

@thushan Thanks for the heads-up. Kicking off a targeted review now, with special attention to the regex-based model rewrite and the fuzz tests around model matching. I’ll follow up with concrete findings and a recommended fix once the script output comes back.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Repo root =="
pwd

echo "== List changed files in PR branch =="
git status -s || true

echo "== Search for rewrite function and regex usage =="
rg -n --type=go -C3 'RewriteModelForAlias|rewriteModel|regexp|regex|model_alias_map|ContextModelAliasMapKey|ValidateModelAliases' internal || true

echo "== Show core/model_rewrite.go =="
fd -a 'model_rewrite.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,220p"'

echo "== Show handler_proxy.go alias-related changes (for context) =="
fd -a 'handler_proxy.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,240p"'

echo "== Show alias resolver implementation =="
fd -a 'alias.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,220p"'

echo "== Search for Go fuzz tests =="
rg -n --type=go -C2 '^func\s+Fuzz' || true

echo "== List alias-related tests recently added =="
fd -a 'handler_proxy_alias_test.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,260p"'

echo "== Show config validation types and tests =="
fd -a 'types.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,220p"'
fd -a 'config_test.go' internal | xargs -I{} sh -c 'echo "--- {} ---"; nl -ba "{}" | sed -n "1,260p"'

Length of output: 49772


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Read model_rewrite.go fully ==="
cat internal/adapter/proxy/core/model_rewrite.go

echo ""
echo "=== Read model_rewrite_test.go to see test coverage ==="
cat internal/adapter/proxy/core/model_rewrite_test.go | head -100

echo ""
echo "=== Check if there are any test files with 'fuzz' in the name ==="
fd -t f -e go fuzz

echo ""
echo "=== Search for any fuzzing-related code ==="
rg -n --type=go 'testing.F|\.Fuzz|FuzzTest' || echo "No fuzzing tests found"

echo ""
echo "=== Check handler_proxy_alias_test.go for edge cases ==="
cat internal/app/handlers/handler_proxy_alias_test.go | head -150

Length of output: 12180


@thushan — Good instinct on the regex. I found the likely culprit.

The Problem

The regex approach has a subtle but critical issue: byte-stream ordering vs. JSON structure mismatch.

The code validates that "model" exists as a top-level key:

var parsed map[string]json.RawMessage
if err := json.Unmarshal(body, &parsed); err != nil {
    return body
}
if _, hasModel := parsed["model"]; !hasModel {
    return body
}

But then the regex ("model"\s*:\s*)"((?:[^"\\]|\\.)*)" searches from the start of the byte stream, not the top-level JSON structure. This means:

Failure case:

{
  "messages": [{"role": "system", "content": "Use model: \"gpt-4\""}],
  "model": "actual-alias"
}
  1. ✅ Unmarshal confirms top-level "model" exists
  2. ❌ Regex matches the first occurrence in the byte stream: the "model: \"gpt-4\"" inside the string content
  3. 💥 Wrong value gets rewritten

Additional fuzz-test triggers:

  • Catastrophic backtracking: The pattern (?:[^"\\]|\\.)* can cause exponential time on inputs with many backslashes (e.g., "model": "a\\\\\\\\\\...")
  • Escaped quotes in nested content: Payloads with \"model\" in message content could confuse the pattern
  • Non-string model values: If fuzz injects "model": 123 or "model": null, the check passes but regex doesn't match (graceful, but unexpected)

The Fix

Replace the regex with a stateful byte-scanner that finds the top-level "model" key:

func rewriteModelField(body []byte, newModel string) []byte {
	// Validate JSON structure first
	var parsed map[string]json.RawMessage
	if err := json.Unmarshal(body, &parsed); err != nil || parsed["model"] == nil {
		return body
	}

	// Locate the top-level "model" key in the byte stream
	// by scanning with a simple state machine that tracks nesting depth
	const (
		stateKey   = 0
		stateValue = 1
		stateString = 2
	)

	var (
		state      = stateKey
		depth      = 0
		inString   = false
		escaped    = false
		modelStart = -1
		modelEnd   = -1
		keyStart   = -1
	)

	for i := 0; i < len(body); i++ {
		b := body[i]

		if escaped {
			escaped = false
			continue
		}

		if b == '\\' && inString {
			escaped = true
			continue
		}

		if b == '"' {
			inString = !inString
			if depth == 1 && !inString && state == stateKey {
				// Just finished reading a key at depth 1
				if keyStart >= 0 && string(body[keyStart:i+1]) == `"model"` {
					// Found top-level "model" key, now find its value
					state = stateValue
				}
				keyStart = -1
			} else if depth == 1 && inString && state == stateKey {
				keyStart = i
			} else if depth == 1 && !inString && state == stateValue {
				// Just finished reading the model value
				modelEnd = i + 1
				break
			} else if depth == 1 && inString && state == stateValue {
				modelStart = i
			}
			continue
		}

		if inString {
			continue
		}

		switch b {
		case '{', '[':
			depth++
			if depth == 1 {
				state = stateKey
			}
		case '}', ']':
			depth--
		case ':':
			if depth == 1 && state == stateKey {
				state = stateValue
			}
		case ',':
			if depth == 1 {
				state = stateKey
			}
		}
	}

	if modelStart < 0 || modelEnd < 0 {
		return body
	}

	// Build the rewritten body
	var buf bytes.Buffer
	buf.Write(body[:modelStart])
	buf.WriteByte('"')
	escapedModel, _ := json.Marshal(newModel)
	buf.Write(escapedModel[1 : len(escapedModel)-1]) // strip quotes
	buf.WriteByte('"')
	buf.Write(body[modelEnd:])
	return buf.Bytes()
}

This approach:

  • ✅ Only matches the top-level "model" key by tracking JSON depth
  • ✅ Preserves byte-identical formatting everywhere else
  • ✅ No regex backtracking — linear time O(n)
  • ✅ Handles escaped quotes, nested "model" fields, UTF-8, all edge cases

Simpler Alternative

If byte-identical formatting isn't critical, just use standard JSON marshal/unmarshal:

func rewriteModelField(body []byte, newModel string) []byte {
	var parsed map[string]interface{}
	if err := json.Unmarshal(body, &parsed); err != nil {
		return body
	}
	if _, ok := parsed["model"]; !ok {
		return body
	}
	parsed["model"] = newModel
	rewritten, err := json.Marshal(parsed)
	if err != nil {
		return body
	}
	return rewritten
}

This loses whitespace/formatting but is bulletproof. Given the JSONL validator threshold issue you mentioned (expecting 5-8% diff but seeing more), preserving formatting might not be working anyway since json.Marshal reorders keys alphabetically.

Let me know which direction you prefer and I can provide a complete drop-in replacement with tests.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@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: 3

🧹 Nitpick comments (3)
internal/adapter/proxy/core/model_rewrite_test.go (1)

154-199: Consider adding test cases for model names containing special JSON characters.

The jsonEscapeString helper handles RFC 8259 escaping, but there are no tests exercising it with adversarial input — e.g., model names containing quotes (my"model), backslashes (path\to\model), or control characters. A few extra table entries would increase confidence in the escaping path.

Suggested additional test cases
 		{
 			name:      "preserves other fields",
 			body:      `{"model": "old", "temperature": 0.7, "messages": []}`,
 			newModel:  "new",
 			wantModel: "new",
 		},
+		{
+			name:      "model name with quotes",
+			body:      `{"model": "old-model"}`,
+			newModel:  `my"model`,
+			wantModel: `my"model`,
+		},
+		{
+			name:      "model name with backslash",
+			body:      `{"model": "old-model"}`,
+			newModel:  `path\to\model`,
+			wantModel: `path\to\model`,
+		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapter/proxy/core/model_rewrite_test.go` around lines 154 - 199,
Add test cases to TestRewriteModelField that exercise jsonEscapeString by
including model names with special JSON characters (e.g., quotes like my"model,
backslashes like path\to\model, and control characters). For each new table
entry, set body to include the original model value, set newModel to the
adversarial string, and assert via the existing rewriteModelField ->
json.Unmarshal parsed["model"] path that the final modelName equals the expected
escaped/unescaped value; reference the test table in TestRewriteModelField and
the jsonEscapeString/rewriteModelField helpers to locate where to add these
entries.
internal/adapter/proxy/core/model_rewrite.go (1)

46-48: ContentLength == 0 may be too broad — consider also checking for -1 (unknown length) bodies.

When ContentLength is 0, the early return is correct. However, note that chunked or streaming request bodies may arrive with ContentLength == -1. The current code handles this correctly (the condition is false, so we proceed to read), but the comment on Line 33 says "The request has no body" — it would be worth noting that ContentLength == 0 specifically means an empty body, not an absent one (that's the r.Body == nil branch).

This is a documentation nit — the logic itself is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapter/proxy/core/model_rewrite.go` around lines 46 - 48, Update
the explanatory comment around the early-return check that uses r.Body and
r.ContentLength to clearly distinguish the cases: state that r.Body == nil means
no body present, r.ContentLength == 0 means an explicitly empty body, and
r.ContentLength == -1 denotes unknown/chunked streaming bodies which should be
processed (i.e., the current code path that proceeds to read). Keep the existing
condition (if r.Body == nil || r.ContentLength == 0 { return }) unchanged; only
modify the comment text near that check to reflect these distinctions and avoid
implying that ContentLength == 0 covers all "no body" cases.
internal/config/types.go (1)

386-391: Consider accepting a logger parameter instead of using the global slog logger.

Calling slog.Warn directly couples this config type to the global logger, making it harder to test log output or silence warnings in tests. An optional refactor would be to accept a *slog.Logger parameter (or use a method receiver that holds one).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/types.go` around lines 386 - 391, The code currently calls
slog.Warn directly (see slog.Warn, aliasName, modelName, seen) which couples
this logic to the global logger; update the function or method that contains
this duplicate-check (the block using seen[modelName]) to accept a logger
parameter (e.g., *slog.Logger) or make the enclosing type hold a logger
receiver, and replace slog.Warn with logger.Warn; ensure the parameter can be
nil by falling back to the global slog only when logger == nil to preserve
current behavior for callers that don't provide one.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/adapter/proxy/core/model_rewrite_test.go`:
- Around line 202-248: Add a unit test (e.g.,
TestRewriteModelField_NestedModelKey) that constructs JSON with a nested "model"
key inside a sub-object and a top-level "model", calls
rewriteModelField([]byte(body), "rewritten"), unmarshals the result into
map[string]json.RawMessage, decodes parsed["model"] into a string and asserts it
equals "rewritten" while ensuring the nested metadata.model remains unchanged;
this documents the current regex limitation and will fail until
rewriteModelField is changed to only target the top-level "model" key.

In `@internal/adapter/proxy/core/model_rewrite.go`:
- Around line 15-19: The current regex modelFieldPattern and its replacement
logic can mistakenly match a nested "model" key; update the rewrite logic that
uses modelFieldPattern (and the replaced flag) to either (A) implement a
depth-aware byte scanner that tracks JSON object brace depth and only
matches/replaces the "model" key when depth == 1 (top-level), preserving
formatting around the matched quoted value, or (B) replace the ad-hoc regex
approach with a proper unmarshal -> modify top-level map[string]interface{} ->
marshal round-trip to reliably change the top-level "model" value; ensure
whichever path you pick still respects escaped quotes and update/remove
modelFieldPattern accordingly.

In `@internal/config/types.go`:
- Around line 379-393: The loop over actualModels should validate and normalize
each model name for leading/trailing whitespace: for each modelName in
actualModels (in the alias validation block using aliasName), trim whitespace
(e.g., strings.TrimSpace) into trimmedName, reject if trimmedName == "" (return
an error like "model alias %q has empty model name at position %d") or if
trimmedName != modelName (return an error about unexpected surrounding
whitespace), and use trimmedName when checking for duplicates in the seen map
and logging; update the duplicate warning to reference trimmedName.

---

Duplicate comments:
In `@internal/adapter/proxy/core/model_rewrite_test.go`:
- Around line 296-315: The test
TestRewriteModelForAlias_BodyReadableAfterRewrite claims body re-readability but
only reads once; update the test to perform a second io.ReadAll(r.Body) after
the first read and assert the second read is non-empty and equals the first (or
equals the original body string) to validate re-readability of the request body
after calling RewriteModelForAlias; reference the test function
TestRewriteModelForAlias_BodyReadableAfterRewrite, the RewriteModelForAlias
call, and r.Body when adding the second read and assertions.

---

Nitpick comments:
In `@internal/adapter/proxy/core/model_rewrite_test.go`:
- Around line 154-199: Add test cases to TestRewriteModelField that exercise
jsonEscapeString by including model names with special JSON characters (e.g.,
quotes like my"model, backslashes like path\to\model, and control characters).
For each new table entry, set body to include the original model value, set
newModel to the adversarial string, and assert via the existing
rewriteModelField -> json.Unmarshal parsed["model"] path that the final
modelName equals the expected escaped/unescaped value; reference the test table
in TestRewriteModelField and the jsonEscapeString/rewriteModelField helpers to
locate where to add these entries.

In `@internal/adapter/proxy/core/model_rewrite.go`:
- Around line 46-48: Update the explanatory comment around the early-return
check that uses r.Body and r.ContentLength to clearly distinguish the cases:
state that r.Body == nil means no body present, r.ContentLength == 0 means an
explicitly empty body, and r.ContentLength == -1 denotes unknown/chunked
streaming bodies which should be processed (i.e., the current code path that
proceeds to read). Keep the existing condition (if r.Body == nil ||
r.ContentLength == 0 { return }) unchanged; only modify the comment text near
that check to reflect these distinctions and avoid implying that ContentLength
== 0 covers all "no body" cases.

In `@internal/config/types.go`:
- Around line 386-391: The code currently calls slog.Warn directly (see
slog.Warn, aliasName, modelName, seen) which couples this logic to the global
logger; update the function or method that contains this duplicate-check (the
block using seen[modelName]) to accept a logger parameter (e.g., *slog.Logger)
or make the enclosing type hold a logger receiver, and replace slog.Warn with
logger.Warn; ensure the parameter can be nil by falling back to the global slog
only when logger == nil to preserve current behavior for callers that don't
provide one.

Comment on lines +379 to +393
// Check each model name in the list
seen := make(map[string]bool)
for i, modelName := range actualModels {
if modelName == "" {
return fmt.Errorf("model alias %q has empty model name at position %d", aliasName, i)
}

// Duplicate model names (warning, not a fatal error)
if seen[modelName] {
slog.Warn("Duplicate model name in alias configuration",
"alias", aliasName,
"model", modelName)
}
seen[modelName] = true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Individual model names are not checked for leading/trailing whitespace.

Alias names are validated for whitespace (Line 370), but the individual model names within the list are not. A model name like " gpt-oss:120b " would pass validation but silently fail to match any endpoint at runtime.

Proposed fix
 		for i, modelName := range actualModels {
 			if modelName == "" {
 				return fmt.Errorf("model alias %q has empty model name at position %d", aliasName, i)
 			}
+			if strings.TrimSpace(modelName) != modelName {
+				return fmt.Errorf("model alias %q has model name with leading/trailing whitespace at position %d: %q", aliasName, i, modelName)
+			}
 
 			// Duplicate model names (warning, not a fatal error)
 			if seen[modelName] {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check each model name in the list
seen := make(map[string]bool)
for i, modelName := range actualModels {
if modelName == "" {
return fmt.Errorf("model alias %q has empty model name at position %d", aliasName, i)
}
// Duplicate model names (warning, not a fatal error)
if seen[modelName] {
slog.Warn("Duplicate model name in alias configuration",
"alias", aliasName,
"model", modelName)
}
seen[modelName] = true
}
// Check each model name in the list
seen := make(map[string]bool)
for i, modelName := range actualModels {
if modelName == "" {
return fmt.Errorf("model alias %q has empty model name at position %d", aliasName, i)
}
if strings.TrimSpace(modelName) != modelName {
return fmt.Errorf("model alias %q has model name with leading/trailing whitespace at position %d: %q", aliasName, i, modelName)
}
// Duplicate model names (warning, not a fatal error)
if seen[modelName] {
slog.Warn("Duplicate model name in alias configuration",
"alias", aliasName,
"model", modelName)
}
seen[modelName] = true
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/types.go` around lines 379 - 393, The loop over actualModels
should validate and normalize each model name for leading/trailing whitespace:
for each modelName in actualModels (in the alias validation block using
aliasName), trim whitespace (e.g., strings.TrimSpace) into trimmedName, reject
if trimmedName == "" (return an error like "model alias %q has empty model name
at position %d") or if trimmedName != modelName (return an error about
unexpected surrounding whitespace), and use trimmedName when checking for
duplicates in the seen map and logging; update the duplicate warning to
reference trimmedName.

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 18, 2026

Wowser, it did detect the issue but I wouldn't write a full on parser. There there code-rabbit...

This is from one of the attempts earlier for model patching that might be useful.

 func rewriteModelField(body []byte, newModel string) []byte {
        dec := json.NewDecoder(bytes.NewReader(body))
        dec.UseNumber()

        depth := 0
        foundModelKey := false

        for {
                off := dec.InputOffset()
                tok, err := dec.Token()
                if err != nil {
                        break
                }

                switch v := tok.(type) {
                case json.Delim:
                        switch v {
                        case '{', '[':
                                depth++
                        case '}', ']':
                                depth--
                        }
                        foundModelKey = false // value was an object/array, not a string
                case string:
                        if foundModelKey {
                                // off points to end of the key token,  scan past ":" & the whitspace
                                // to find where the actual value starts in the raw bytes
                                endOff := dec.InputOffset()
                                valueStart := off
                                for valueStart < endOff && (body[valueStart] == ':' || body[valueStart] == ' ' ||
                                        body[valueStart] == '\t' || body[valueStart] == '\n' || body[valueStart] == '\r') {
                                        valueStart++
                                }
                                newValue, _ := json.Marshal(newModel)
                                var result []byte
                                result = append(result, body[:valueStart]...)
                                result = append(result, newValue...)
                                result = append(result, body[endOff:]...)
                                return result
                        }
                        if depth == 1 && v == "model" {
                                foundModelKey = true
                        }
                default:
                        foundModelKey = false // value was number/bool or nil, not a string
                }
        }
        return body // no models in the body
  }

because it keeps to depth == 1, we avoid too much overhead and avoids that double Unmarshal'ing.

@thushan
Copy link
Copy Markdown
Owner

thushan commented Feb 20, 2026

Hey mate, we'll get 0.0.23 released and target this for 0.0.24 so it gives you time to refactor etc.

The regex approach could falsely match the word 'model' inside nested
JSON string values (e.g. message content like "Use model: gpt-4"),
causing incorrect rewrites and failing fuzz tests.

Replace with a streaming json.Decoder that walks only top-level object
keys via InputOffset to precisely locate the value's byte range, then
splices in the replacement — preserving all surrounding whitespace, key
ordering, and formatting. Also removes the regexp import.

Add tests covering: model name in nested message content, model key in
nested objects, and model names with special characters requiring JSON
escaping.
@dnnspaul
Copy link
Copy Markdown
Contributor Author

Sorry for the delay! Pushed a fix for the regex issue you flagged — swapped it out for a proper json.Decoder token scanner that walks only top-level keys, so nested content like message strings with the word "model" in them can't accidentally get rewritten anymore. Also added test cases covering exactly those scenarios. Should be solid now!

@thushan
Copy link
Copy Markdown
Owner

thushan commented Mar 29, 2026

Amazing work, there are some conflicts, if you can resolve those i'll get this reviewed tomorrow nad hopefully merged 🥳

@thushan
Copy link
Copy Markdown
Owner

thushan commented Mar 29, 2026

Great work @dnnspaul, everythings green, now the pressure's on me to review 😰 will update soon.

Copy link
Copy Markdown
Owner

@thushan thushan left a comment

Choose a reason for hiding this comment

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

Excellent work, i think youv'e done an amazing job! thank-you for your contribution and especially for writing/including docs.

We'll merge this and do a full test and prepare a new release hopefully before Easter.

@thushan thushan merged commit b58ae65 into thushan:main Mar 30, 2026
8 checks passed
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.

Defining model aliases

2 participants