将全局启用、用户组基线、API Key 专属或排除规则及 scope 按固定顺序求值,避免 Key 越过所属用户组权限,并让运行时候选与模型列表共用同一权限链。 新增 Key 级可分配模型与失效规则诊断接口、OpenAI 兼容 /v1/models 及 rich 列表迁移路径;前端权限弹窗改为按当前 Key 实时加载并支持清理失效规则。 验证:Go 全量测试与 go vet 通过;Web 22 个测试文件共 142 项通过;pnpm lint、pnpm openapi、pnpm build、Compose 配置、gofmt、ShellCheck 和 git diff --check 通过;独立 PostgreSQL 真实配置验收通过。
167 lines
4.2 KiB
Go
167 lines
4.2 KiB
Go
package modelaccess
|
|
|
|
import "strings"
|
|
|
|
// ScopeAllowsTask reports whether scopes authorize one public task kind.
|
|
// Empty scopes retain the legacy unrestricted behavior for old API keys.
|
|
func ScopeAllowsTask(scopes []string, kind string) bool {
|
|
if len(scopes) == 0 {
|
|
return true
|
|
}
|
|
return scopeAllowsCapability(scopes, capabilityForTaskKind(kind))
|
|
}
|
|
|
|
// ScopeAllowsModelType reports whether scopes authorize one runtime model type.
|
|
// Unknown model types are deny-by-default unless the key has all or an exact
|
|
// custom scope matching that model type.
|
|
func ScopeAllowsModelType(scopes []string, modelType string) bool {
|
|
if len(scopes) == 0 {
|
|
return true
|
|
}
|
|
modelType = normalize(modelType)
|
|
if modelType == "" {
|
|
return false
|
|
}
|
|
capability := capabilityForModelType(modelType)
|
|
if capability == "" {
|
|
return hasScope(scopes, modelType)
|
|
}
|
|
return scopeAllowsCapability(scopes, capability)
|
|
}
|
|
|
|
// FilterModelTypes keeps the declared order while removing model types that
|
|
// the key cannot invoke.
|
|
func FilterModelTypes(scopes []string, modelTypes []string) []string {
|
|
if len(modelTypes) == 0 {
|
|
return nil
|
|
}
|
|
filtered := make([]string, 0, len(modelTypes))
|
|
seen := map[string]bool{}
|
|
for _, modelType := range modelTypes {
|
|
normalized := normalize(modelType)
|
|
if normalized == "" || seen[normalized] || !ScopeAllowsModelType(scopes, normalized) {
|
|
continue
|
|
}
|
|
seen[normalized] = true
|
|
filtered = append(filtered, normalized)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func capabilityForTaskKind(kind string) string {
|
|
switch normalize(kind) {
|
|
case "chat.completions", "responses":
|
|
return "chat"
|
|
case "embeddings":
|
|
return "embedding"
|
|
case "reranks":
|
|
return "rerank"
|
|
case "images.generations", "images.edits":
|
|
return "image"
|
|
case "images.vectorize":
|
|
return "image_vectorize"
|
|
case "videos.generations":
|
|
return "video"
|
|
case "videos.upscales":
|
|
return "video_enhance"
|
|
case "song.generations", "music.generations":
|
|
return "music"
|
|
case "speech.generations":
|
|
return "audio"
|
|
case "voice.clone":
|
|
return "voice_clone"
|
|
default:
|
|
return normalize(kind)
|
|
}
|
|
}
|
|
|
|
func capabilityForModelType(modelType string) string {
|
|
switch normalize(modelType) {
|
|
case "text_generate", "tools_call":
|
|
return "chat"
|
|
case "text_embedding":
|
|
return "embedding"
|
|
case "text_rerank":
|
|
return "rerank"
|
|
case "image_generate", "image_edit", "image_analysis":
|
|
return "image"
|
|
case "image_vectorize":
|
|
return "image_vectorize"
|
|
case "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "video_understanding", "omni_video", "omni":
|
|
return "video"
|
|
case "video_enhance":
|
|
return "video_enhance"
|
|
case "audio_generate", "music_generate":
|
|
return "music"
|
|
case "text_to_speech", "audio_understanding":
|
|
return "audio"
|
|
case "voice_clone":
|
|
return "voice_clone"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func scopeAllowsCapability(scopes []string, capability string) bool {
|
|
capability = normalize(capability)
|
|
if capability == "" {
|
|
return false
|
|
}
|
|
for _, scope := range scopes {
|
|
scope = normalize(scope)
|
|
if scope == "*" || scope == "all" || scope == capability {
|
|
return true
|
|
}
|
|
switch capability {
|
|
case "chat":
|
|
if scope == "text" || scope == "text_generate" {
|
|
return true
|
|
}
|
|
case "embedding":
|
|
if scope == "text_embedding" {
|
|
return true
|
|
}
|
|
case "rerank":
|
|
if scope == "text_rerank" {
|
|
return true
|
|
}
|
|
case "music":
|
|
if scope == "audio_generate" || scope == "music_generate" || scope == "song" {
|
|
return true
|
|
}
|
|
case "audio":
|
|
if scope == "text_to_speech" || scope == "speech" || scope == "tts" {
|
|
return true
|
|
}
|
|
case "voice_clone":
|
|
if scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts" {
|
|
return true
|
|
}
|
|
case "image_vectorize":
|
|
if scope == "image" || scope == "vectorize" {
|
|
return true
|
|
}
|
|
case "video_enhance":
|
|
if scope == "video" || scope == "video_upscale" || scope == "upscale" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasScope(scopes []string, want string) bool {
|
|
want = normalize(want)
|
|
for _, scope := range scopes {
|
|
scope = normalize(scope)
|
|
if scope == "*" || scope == "all" || scope == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalize(value string) string {
|
|
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "-", "_")
|
|
}
|