Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ type Provider struct {

// 支持的 Client
SupportedClientTypes []ClientType `json:"supportedClientTypes"`

// 支持的模型列表(通配符模式)
// 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中
// 空数组表示支持所有模型
SupportModels []string `json:"supportModels,omitempty"`
Comment on lines +99 to +103
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

注释与实际实现可能不一致。

注释中提到"在 Route 匹配时会检查前置映射后的模型是否在支持列表中",但根据 PR 目标和 router.go 中的实现,SupportModels 检查使用的是原始 requestModel(映射前),模型映射是在 Executor 阶段执行的。

建议修正注释以匹配实际行为:

📝 建议的注释修改
-	// 支持的模型列表(通配符模式)
-	// 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中
-	// 空数组表示支持所有模型
+	// 支持的模型列表(通配符模式)
+	// 如果配置了,在 Route 匹配时会检查原始请求模型是否在支持列表中
+	// 模型映射在 Executor 阶段执行,晚于此检查
+	// 空数组表示支持所有模型
📝 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
// 支持的模型列表(通配符模式)
// 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中
// 空数组表示支持所有模型
SupportModels []string `json:"supportModels,omitempty"`
// 支持的模型列表(通配符模式)
// 如果配置了,在 Route 匹配时会检查原始请求模型是否在支持列表中
// 模型映射在 Executor 阶段执行,晚于此检查
// 空数组表示支持所有模型
SupportModels []string `json:"supportModels,omitempty"`
🤖 Prompt for AI Agents
In `@internal/domain/model.go` around lines 99 - 103, 注释与实现不一致:在 Route 匹配时字段
SupportModels 实际上是基于原始 requestModel(映射前)进行检查,而模型映射发生在 Executor 阶段;请在
internal/domain/model.go 中更新 SupportModels 的注释以明确说明它匹配的是映射前的原始模型(用于 router.go
的路由匹配),并补充说明模型映射是在 Executor 阶段完成(例如引用 Executor 或模型映射流程)以避免误导。

}

type Project struct {
Expand Down
8 changes: 7 additions & 1 deletion internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
}

// Match routes
routes, err := e.router.Match(clientType, projectID)
routes, err := e.router.Match(&router.MatchContext{
ClientType: clientType,
ProjectID: projectID,
RequestModel: requestModel,
APITokenID: apiTokenID,
})
if err != nil {
proxyReq.Status = "FAILED"
proxyReq.Error = "no routes available"
Expand Down Expand Up @@ -258,6 +263,7 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
}

// Determine model mapping
// Model mapping is done in Executor after Router has filtered by SupportModels
clientType := ctxutil.GetClientType(ctx)
mappedModel := e.mapModel(requestModel, matchedRoute.Route, matchedRoute.Provider, clientType, projectID, apiTokenID)
ctx = ctxutil.WithMappedModel(ctx, mappedModel)
Expand Down
1 change: 1 addition & 0 deletions internal/repository/sqlite/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type Provider struct {
Name string `gorm:"not null"`
Config string `gorm:"type:longtext"`
SupportedClientTypes string `gorm:"type:text"`
SupportModels string `gorm:"type:text"`
}

func (Provider) TableName() string { return "providers" }
Expand Down
2 changes: 2 additions & 0 deletions internal/repository/sqlite/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func (r *ProviderRepository) toModel(p *domain.Provider) *Provider {
Name: p.Name,
Config: toJSON(p.Config),
SupportedClientTypes: toJSON(p.SupportedClientTypes),
SupportModels: toJSON(p.SupportModels),
}
}

Expand All @@ -98,5 +99,6 @@ func (r *ProviderRepository) toDomain(m *Provider) *domain.Provider {
Name: m.Name,
Config: fromJSON[*domain.ProviderConfig](m.Config),
SupportedClientTypes: fromJSON[[]domain.ClientType](m.SupportedClientTypes),
SupportModels: fromJSON[[]string](m.SupportModels),
}
}
37 changes: 34 additions & 3 deletions internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ type MatchedRoute struct {
RetryConfig *domain.RetryConfig
}

// MatchContext contains all context needed for route matching
type MatchContext struct {
ClientType domain.ClientType
ProjectID uint64
RequestModel string
APITokenID uint64
}

// Router handles route matching and selection
type Router struct {
routeRepo *cached.RouteRepository
Expand Down Expand Up @@ -98,7 +106,11 @@ func (r *Router) RemoveAdapter(providerID uint64) {
}

// Match returns matched routes for a client type and project
func (r *Router) Match(clientType domain.ClientType, projectID uint64) ([]*MatchedRoute, error) {
func (r *Router) Match(ctx *MatchContext) ([]*MatchedRoute, error) {
clientType := ctx.ClientType
projectID := ctx.ProjectID
requestModel := ctx.RequestModel

routes := r.routeRepo.GetAll()

// Check if ClientType has custom routes enabled for this project
Expand Down Expand Up @@ -175,7 +187,7 @@ func (r *Router) Match(clientType domain.ClientType, projectID uint64) ([]*Match
providers := r.providerRepo.GetAll()

for _, route := range filtered {
provider, ok := providers[route.ProviderID]
prov, ok := providers[route.ProviderID]
if !ok {
continue
}
Expand All @@ -190,6 +202,15 @@ func (r *Router) Match(clientType domain.ClientType, projectID uint64) ([]*Match
continue
}

// Check if provider supports the request model
// SupportModels check is done BEFORE mapping
// If SupportModels is configured, check if the request model is supported
if len(prov.SupportModels) > 0 && requestModel != "" {
if !r.isModelSupported(requestModel, prov.SupportModels) {
continue
}
}

var retryConfig *domain.RetryConfig
if route.RetryConfigID != 0 {
retryConfig, _ = r.retryConfigRepo.GetByID(route.RetryConfigID)
Expand All @@ -200,7 +221,7 @@ func (r *Router) Match(clientType domain.ClientType, projectID uint64) ([]*Match

matched = append(matched, &MatchedRoute{
Route: route,
Provider: provider,
Provider: prov,
ProviderAdapter: adp,
RetryConfig: retryConfig,
})
Expand All @@ -213,6 +234,16 @@ func (r *Router) Match(clientType domain.ClientType, projectID uint64) ([]*Match
return matched, nil
}

// isModelSupported checks if a model matches any pattern in the support list
func (r *Router) isModelSupported(model string, supportModels []string) bool {
for _, pattern := range supportModels {
if domain.MatchWildcard(pattern, model) {
return true
}
}
return false
}

func (r *Router) getRoutingStrategy(projectID uint64) *domain.RoutingStrategy {
// Try project-specific strategy first
if projectID != 0 {
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface Provider {
logo?: string; // Logo URL or data URI
config: ProviderConfig | null;
supportedClientTypes: ClientType[];
supportModels?: string[]; // 支持的模型列表(通配符模式),空数组表示支持所有模型
}

// supportedClientTypes 可选,后端会根据 provider type 自动设置
Expand All @@ -57,6 +58,7 @@ export type CreateProviderData = Omit<
'id' | 'createdAt' | 'updatedAt' | 'supportedClientTypes'
> & {
supportedClientTypes?: ClientType[];
supportModels?: string[];
};

// ===== Project =====
Expand Down
102 changes: 101 additions & 1 deletion web/src/pages/providers/components/provider-edit-flow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { Globe, ChevronLeft, Key, Check, Trash2, Plus, ArrowRight, Zap } from 'lucide-react'
import { Globe, ChevronLeft, Key, Check, Trash2, Plus, ArrowRight, Zap, Filter } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
Expand Down Expand Up @@ -167,6 +167,97 @@ function ProviderModelMappings({ provider }: { provider: Provider }) {
)
}

// Provider Supported Models Section
function ProviderSupportModels({
supportModels,
onChange,
}: {
supportModels: string[]
onChange: (models: string[]) => void
}) {
const { t } = useTranslation()
const [newModel, setNewModel] = useState('')

const handleAddModel = () => {
if (!newModel.trim()) return
const trimmedModel = newModel.trim()
if (!supportModels.includes(trimmedModel)) {
onChange([...supportModels, trimmedModel])
}
setNewModel('')
}

const handleRemoveModel = (model: string) => {
onChange(supportModels.filter(m => m !== model))
}

return (
<div>
<div className="flex items-center gap-2 mb-4 border-b border-border pb-2">
<Filter size={18} className="text-blue-500" />
<h4 className="text-lg font-semibold text-foreground">
{t('providers.supportModels.title', 'Supported Models')}
</h4>
<span className="text-sm text-muted-foreground">
({supportModels.length})
</span>
</div>

<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-4">
{t('providers.supportModels.desc', 'Configure which models this provider supports. If empty, all models are supported. Supports wildcards like claude-* or gemini-*.')}
</p>

{supportModels.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{supportModels.map((model) => (
<div
key={model}
className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg px-3 py-1.5"
>
<span className="text-sm">{model}</span>
<button
type="button"
onClick={() => handleRemoveModel(model)}
className="text-muted-foreground hover:text-destructive ml-1"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
Comment on lines +219 to +225
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

无障碍性:删除按钮缺少 aria-label

删除按钮没有提供屏幕阅读器可识别的标签,这会影响无障碍访问体验。

♿ 建议的修复
                 <button
                   type="button"
                   onClick={() => handleRemoveModel(model)}
                   className="text-muted-foreground hover:text-destructive ml-1"
+                  aria-label={t('common.remove', 'Remove') + ` ${model}`}
                 >
                   <Trash2 className="h-3.5 w-3.5" />
                 </button>
📝 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
<button
type="button"
onClick={() => handleRemoveModel(model)}
className="text-muted-foreground hover:text-destructive ml-1"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleRemoveModel(model)}
className="text-muted-foreground hover:text-destructive ml-1"
aria-label={t('common.remove', 'Remove') + ` ${model}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
🤖 Prompt for AI Agents
In `@web/src/pages/providers/components/provider-edit-flow.tsx` around lines 219 -
225, 删除按钮缺少屏幕阅读器标签:在该 <button>(触发 handleRemoveModel 的按钮,包含 Trash2
图标)上添加一个描述性无障碍属性(例如 aria-label="删除模型" 或 aria-label={t('Remove model')}
以支持本地化),也可同时保留 title 属性以增强可见提示,确保按钮仍为 type="button" 并保持现有 onClick 处理器不变。

</div>
))}
</div>
)}

{supportModels.length === 0 && (
<div className="text-center py-6 mb-4">
<p className="text-muted-foreground text-sm">
{t('providers.supportModels.empty', 'No model filter configured. All models will be supported.')}
</p>
</div>
)}

<div className="flex items-center gap-2 pt-4 border-t border-border">
<ModelInput
value={newModel}
onChange={setNewModel}
placeholder={t('providers.supportModels.placeholder', 'e.g. claude-* or gemini-2.5-*')}
className="flex-1 min-w-0 h-8 text-sm"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddModel}
disabled={!newModel.trim()}
>
<Plus className="h-4 w-4 mr-1" />
{t('common.add')}
</Button>
</div>
</div>
</div>
)
}

interface ProviderEditFlowProps {
provider: Provider;
onClose: () => void;
Expand All @@ -177,6 +268,7 @@ type EditFormData = {
baseURL: string;
apiKey: string;
clients: ClientConfig[];
supportModels: string[];
};

export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
Expand All @@ -202,6 +294,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
baseURL: provider.config?.custom?.baseURL || '',
apiKey: provider.config?.custom?.apiKey || '',
clients: initClients(),
supportModels: provider.supportModels || [],
});

const updateClient = (clientId: ClientType, updates: Partial<ClientConfig>) => {
Expand Down Expand Up @@ -245,6 +338,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
},
},
supportedClientTypes,
supportModels: formData.supportModels.length > 0 ? formData.supportModels : undefined,
};

await updateProvider.mutateAsync({ id: Number(provider.id), data });
Expand Down Expand Up @@ -420,6 +514,12 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
<ClientsConfigSection clients={formData.clients} onUpdateClient={updateClient} />
</div>

{/* Provider Supported Models Filter */}
<ProviderSupportModels
supportModels={formData.supportModels}
onChange={(models) => setFormData((prev) => ({ ...prev, supportModels: models }))}
/>

{/* Provider Model Mappings */}
<ProviderModelMappings provider={provider} />

Expand Down