Files
sub2api/backend/internal/service/composite_model_route.go
T
visa2 386b57bb77 fix(composite): pass the requested model through when a prefix route leaves upstream_model empty
Composite model routes support a match_type: prefix mode with an optional
upstream_model. Leaving upstream_model empty is meant to pass the concrete
requested model through to the upstream, but every request matching the prefix
collapsed to the route's public_model instead. A route with
public_model=deepseek-v4, match_type=prefix and an empty upstream_model
forwarded both deepseek-v4-flash and deepseek-v4-pro upstream as deepseek-v4.

Root cause: normalizeCompositeRouteInput backfilled an empty upstream_model with
public_model at save time, so CompositeRouteResolver.Resolve never reached its
"upstreamModel == '' -> concrete requested model" fallback.

- Skip the backfill for prefix routes only; an empty upstream_model stays empty
  and Resolve passes the concrete requested model through.
- exact routes keep the backfill (model == public_model, so persisted/display
  contract is unchanged).
- Explicit upstream_model still forwards that fixed model in every mode.
- Frontend: add an upstream_model hint (zh/en) documenting empty = passthrough.
2026-07-25 10:20:50 +08:00

132 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
CompositeRouteMatchExact = "exact"
CompositeRouteMatchPrefix = "prefix"
CompositeRouteEndpointAny = "any"
CompositeRouteEndpointMessages = "messages"
CompositeRouteEndpointCountTokens = "count_tokens"
CompositeRouteEndpointResponses = "responses"
CompositeRouteEndpointChatCompletions = "chat_completions"
CompositeRouteEndpointEmbeddings = "embeddings"
CompositeRouteEndpointImages = "images"
CompositeRouteEndpointGemini = "gemini"
CompositeRouteSourceExplicit = "route"
CompositeRouteSourceDetector = "detector"
)
var (
ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found")
ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists")
)
// CompositeModelRoute maps one public model identifier in a composite group to
// the concrete provider/model that should handle the request.
type CompositeModelRoute struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
PublicModel string `json:"public_model"`
MatchType string `json:"match_type"`
TargetPlatform string `json:"target_platform"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CompositeRoutePreviewRequest struct {
Model string `json:"model"`
Endpoint string `json:"endpoint"`
}
type CompositeRouteDecision struct {
Matched bool `json:"matched"`
Source string `json:"source"`
GroupID int64 `json:"group_id"`
PublicModel string `json:"public_model"`
TargetPlatform string `json:"target_platform"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint"`
Route *CompositeModelRoute `json:"route,omitempty"`
Reason string `json:"reason,omitempty"`
}
type CompositeRouteInput struct {
PublicModel string
MatchType string
TargetPlatform string
UpstreamModel string
Endpoint string
Priority int
Enabled bool
Notes string
}
type CompositeModelRouteRepository interface {
ListByGroup(ctx context.Context, groupID int64, includeDisabled bool) ([]CompositeModelRoute, error)
Create(ctx context.Context, route *CompositeModelRoute) error
Update(ctx context.Context, route *CompositeModelRoute) error
Delete(ctx context.Context, id int64) error
DeleteByGroup(ctx context.Context, groupID int64) error
}
func normalizeCompositeRouteEndpoint(endpoint string) string {
endpoint = strings.ToLower(strings.TrimSpace(endpoint))
if endpoint == "" {
return CompositeRouteEndpointAny
}
switch endpoint {
case CompositeRouteEndpointMessages,
CompositeRouteEndpointCountTokens,
CompositeRouteEndpointResponses,
CompositeRouteEndpointChatCompletions,
CompositeRouteEndpointEmbeddings,
CompositeRouteEndpointImages,
CompositeRouteEndpointGemini:
return endpoint
default:
return CompositeRouteEndpointAny
}
}
func normalizeCompositeRouteMatchType(matchType string) string {
matchType = strings.ToLower(strings.TrimSpace(matchType))
switch matchType {
case CompositeRouteMatchPrefix:
return CompositeRouteMatchPrefix
default:
return CompositeRouteMatchExact
}
}
func normalizeCompositeRouteInput(input CompositeRouteInput) CompositeRouteInput {
input.PublicModel = strings.TrimSpace(input.PublicModel)
input.MatchType = normalizeCompositeRouteMatchType(input.MatchType)
input.TargetPlatform = strings.TrimSpace(input.TargetPlatform)
input.UpstreamModel = strings.TrimSpace(input.UpstreamModel)
input.Endpoint = normalizeCompositeRouteEndpoint(input.Endpoint)
// 仅对 exact 路由把空 upstream_model 回填成 public_model:exact 命中时请求模型
// 恒等于 public_model,回填只影响持久化/后台展示,保留原有契约不变。
// prefix 路由留空则不回填——Resolve 会回退到具体请求模型,从而透传原始模型
// (否则 public=deepseek-v4 的前缀路由会把 deepseek-v4-flash / deepseek-v4-pro
// 都塌缩成固定的 deepseek-v4)。显式填写 upstream_model 时任何模式都原样固定转发。
if input.UpstreamModel == "" && input.MatchType == CompositeRouteMatchExact {
input.UpstreamModel = input.PublicModel
}
input.Notes = strings.TrimSpace(input.Notes)
return input
}