mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
An OpenAI group that keeps a failover account for a shared public alias
produced a Codex model descriptor with no reasoning levels and text-only
input, which leaves the client's reasoning-effort slider with nothing to
select.
Two accounts explicitly mapping one alias to different upstream models
makes codexExplicitModelTargetsConflictForPlatform true. With no
upstream_model_metadata snapshot to intersect, groupCodexModelMetadata
short-circuits to {reasoningConflict, inputModalitiesConflict}, so
DefaultReasoningLevel is nil (the key vanishes under omitempty) and
SupportedReasoningLevels is an empty array. mergeMissingCodexModelFields
fills only missing-or-null fields, so an empty array is never repaired.
Failing closed is right when the alias target is genuinely ambiguous, but
a group that declares model_routing for the alias has already resolved
that ambiguity: the operator stated which accounts serve it and which is
primary. Treating that as a conflict rejects a deliberate failover setup.
Manifest side: when a routing rule names the alias, restrict capability
declaration to the routed accounts and skip the conflict override.
Capabilities then fall back to the same by-name derivation used for a
single account without a metadata snapshot. Groups with no rule, a
disabled rule, or a rule for another alias keep failing closed.
Scheduling side: model_routing was stored and read platform-agnostically
(admin_group.go, Group.GetRoutingAccountIDs) but four gates in
gateway_scheduling.go only consulted it for Anthropic, so an OpenAI
group's rules were accepted and displayed yet never honored. Without
this, the manifest would honor a rule that scheduling ignores. Both
gates now share one predicate, with the allowed platform set defined in
a single place.
Selection inside a routing list is unchanged: the list is a membership
set, and the winner is still decided by Priority, then load, then
LastUsedAt.
Tests: alias-failover manifest recovery plus the three negative cases,
the platform predicate table, and an OpenAI routed selection that must
beat a higher-priority unrouted account.
455 lines
14 KiB
Go
455 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
var codexToolCapabilityFields = []string{
|
|
"supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite",
|
|
}
|
|
|
|
func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool {
|
|
changed := false
|
|
for _, field := range codexToolCapabilityFields {
|
|
value := bytes.TrimSpace(src[field])
|
|
if len(value) == 0 {
|
|
continue
|
|
}
|
|
// These five Codex fields are nullable booleans or strings, never arbitrary objects.
|
|
if !bytes.Equal(value, []byte("null")) {
|
|
if field == "supports_search_tool" || field == "use_responses_lite" {
|
|
if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) {
|
|
continue
|
|
}
|
|
} else {
|
|
var text string
|
|
if json.Unmarshal(value, &text) != nil {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
current, exists := dst[field]
|
|
if (exists && !overwrite) || bytes.Equal(current, value) {
|
|
continue
|
|
}
|
|
dst[field] = append(json.RawMessage(nil), value...)
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func accountCodexToolCapabilities(account *Account, modelID string) map[string]json.RawMessage {
|
|
capabilities := make(map[string]json.RawMessage)
|
|
if account == nil {
|
|
return capabilities
|
|
}
|
|
if metadata, ok := account.GetUpstreamModelMetadata(modelID); ok {
|
|
applyCodexToolCapabilities(capabilities, metadata.CodexToolCapabilities, true)
|
|
}
|
|
if account.IsOpenAI() && shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
|
// This bridge implements client-side tool discovery, even without a native manifest.
|
|
applyCodexToolCapabilities(capabilities, map[string]json.RawMessage{"supports_search_tool": json.RawMessage("true")}, false)
|
|
}
|
|
// Codex 0.153's bundled Astra catalog verifies these values. API-key routes
|
|
// use standard Responses, not the ChatGPT-only Responses Lite wire.
|
|
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
|
if baseURL == "" {
|
|
baseURL = account.GetOpenAIBaseURL()
|
|
}
|
|
parsed, err := url.Parse(baseURL)
|
|
official := err == nil && (strings.EqualFold(parsed.Hostname(), "api.openai.com") ||
|
|
(account.IsOpenAIOAuth() && strings.EqualFold(parsed.Hostname(), "chatgpt.com")))
|
|
if account.IsOpenAI() && isOpenAIGPT6AstraModel(modelID) && official {
|
|
defaults := map[string]json.RawMessage{
|
|
"supports_search_tool": json.RawMessage("true"),
|
|
"apply_patch_tool_type": json.RawMessage(`"freeform"`),
|
|
"comp_hash": json.RawMessage(`"3000"`),
|
|
"tool_mode": json.RawMessage("null"),
|
|
"use_responses_lite": json.RawMessage("false"),
|
|
}
|
|
if account.IsOpenAIOAuth() {
|
|
defaults["tool_mode"] = json.RawMessage(`"code_mode_only"`)
|
|
defaults["use_responses_lite"] = json.RawMessage("true")
|
|
}
|
|
applyCodexToolCapabilities(capabilities, defaults, false)
|
|
}
|
|
if account.IsOpenAIApiKey() {
|
|
target := modelID
|
|
if isOpenAIGPT6AstraModel(target) {
|
|
target = "gpt-6-astra"
|
|
}
|
|
_, disabled := apiKeyCodexModelsWithoutResponsesLite[target]
|
|
if disabled && bytes.Equal(capabilities["use_responses_lite"], []byte("true")) {
|
|
capabilities["use_responses_lite"] = json.RawMessage("false")
|
|
}
|
|
}
|
|
return capabilities
|
|
}
|
|
|
|
// codexModelRoutingAccountIDs 返回分组显式为该公开别名声明的账号集合。
|
|
//
|
|
// 返回非空表示运营者已经用 model_routing 指明“这个别名由这些账号服务”,此时别名的
|
|
// 归属不再是需要推断的未知量:能力声明只看这些账号,且不再因为它们映射到不同上游而
|
|
// 判定为冲突。返回空表示没有相关规则,保持原有的全量推断与失败即关闭行为。
|
|
func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 {
|
|
if group == nil {
|
|
return nil
|
|
}
|
|
return group.GetRoutingAccountIDs(strings.TrimSpace(modelID))
|
|
}
|
|
|
|
func groupCodexModelMetadata(
|
|
platform string,
|
|
modelID string,
|
|
accounts []Account,
|
|
group *Group,
|
|
compositeRoutes []CompositeModelRoute,
|
|
compositeRoutesAvailable bool,
|
|
) (codexModelMetadataOverride, bool) {
|
|
modelID = strings.TrimSpace(modelID)
|
|
if modelID == "" {
|
|
return codexModelMetadataOverride{}, false
|
|
}
|
|
// 显式路由规则本身与平台无关,先取出来供后续两处判定共用。
|
|
routedAccountIDs := codexModelRoutingAccountIDs(group, modelID)
|
|
routed := len(routedAccountIDs) > 0
|
|
upstreamModel := modelID
|
|
if platform == PlatformComposite {
|
|
var resolved bool
|
|
platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
|
|
modelID,
|
|
accounts,
|
|
compositeRoutes,
|
|
compositeRoutesAvailable,
|
|
)
|
|
if !resolved {
|
|
if !routed && codexExplicitModelTargetsConflict(accounts, modelID) {
|
|
return codexModelMetadataOverride{
|
|
reasoningConflict: true,
|
|
inputModalitiesConflict: true,
|
|
}, true
|
|
}
|
|
return codexModelMetadataOverride{}, false
|
|
}
|
|
}
|
|
if !isConcreteRequestPlatform(platform) {
|
|
return codexModelMetadataOverride{}, false
|
|
}
|
|
|
|
explicitClaims := false
|
|
if upstreamModel == modelID {
|
|
for i := range accounts {
|
|
account := &accounts[i]
|
|
if routed && !containsInt64(routedAccountIDs, account.ID) {
|
|
continue
|
|
}
|
|
if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) {
|
|
explicitClaims = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// 别名已被显式路由声明时,"多个账号映射到不同上游"是故障转移的正常写法,不是歧义,
|
|
// 因此不再失败即关闭;能力回落到与单账号无快照时相同的按名推导。
|
|
explicitTargetsConflict := explicitClaims && !routed &&
|
|
codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID)
|
|
publicAlias := upstreamModel != modelID
|
|
candidates := make([]UpstreamModelMetadata, 0)
|
|
missingMetadata := false
|
|
for i := range accounts {
|
|
account := &accounts[i]
|
|
if account.Platform != platform {
|
|
continue
|
|
}
|
|
if routed && !containsInt64(routedAccountIDs, account.ID) {
|
|
continue
|
|
}
|
|
var lookupModel string
|
|
if explicitClaims {
|
|
if !codexExplicitModelMappingClaims(*account, modelID) {
|
|
continue
|
|
}
|
|
lookupModel = account.GetMappedModel(modelID)
|
|
} else {
|
|
if !account.IsModelSupported(upstreamModel) {
|
|
continue
|
|
}
|
|
lookupModel = account.GetMappedModel(upstreamModel)
|
|
}
|
|
if strings.TrimSpace(lookupModel) != modelID {
|
|
publicAlias = true
|
|
}
|
|
metadata, ok := account.GetUpstreamModelMetadata(lookupModel)
|
|
if !ok {
|
|
if explicitTargetsConflict {
|
|
return codexModelMetadataOverride{
|
|
reasoningConflict: true,
|
|
inputModalitiesConflict: true,
|
|
}, true
|
|
}
|
|
missingMetadata = true
|
|
}
|
|
metadata.CodexToolCapabilities = accountCodexToolCapabilities(account, lookupModel)
|
|
candidates = append(candidates, metadata)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return codexModelMetadataOverride{}, false
|
|
}
|
|
metadata := intersectUpstreamModelMetadata(modelID, candidates)
|
|
if missingMetadata {
|
|
metadata = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{
|
|
CodexToolCapabilities: metadata.CodexToolCapabilities,
|
|
}}
|
|
}
|
|
if publicAlias {
|
|
metadata.DisplayName = modelID
|
|
metadata.Description = configuredCodexCustomDescription
|
|
}
|
|
return metadata, true
|
|
}
|
|
|
|
func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool {
|
|
targets := make(map[string]struct{})
|
|
for i := range accounts {
|
|
account := &accounts[i]
|
|
mappedModel, matched := account.ResolveMappedModel(modelID)
|
|
mappedModel = strings.TrimSpace(mappedModel)
|
|
if !matched || mappedModel == "" {
|
|
continue
|
|
}
|
|
targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{}
|
|
}
|
|
return len(targets) > 1
|
|
}
|
|
|
|
func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool {
|
|
targets := make(map[string]struct{})
|
|
for i := range accounts {
|
|
account := &accounts[i]
|
|
if account.Platform != platform {
|
|
continue
|
|
}
|
|
mappedModel, matched := account.ResolveMappedModel(modelID)
|
|
mappedModel = strings.TrimSpace(mappedModel)
|
|
if !matched || mappedModel == "" {
|
|
continue
|
|
}
|
|
targets[mappedModel] = struct{}{}
|
|
}
|
|
return len(targets) > 1
|
|
}
|
|
|
|
func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride {
|
|
result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}}
|
|
result.CodexToolCapabilities = make(map[string]json.RawMessage)
|
|
for _, field := range codexToolCapabilityFields {
|
|
value := bytes.TrimSpace(candidates[0].CodexToolCapabilities[field])
|
|
shared := len(value) > 0
|
|
declared := shared
|
|
for _, candidate := range candidates[1:] {
|
|
declared = declared || len(candidate.CodexToolCapabilities[field]) > 0
|
|
if !bytes.Equal(value, bytes.TrimSpace(candidate.CodexToolCapabilities[field])) {
|
|
shared = false
|
|
}
|
|
}
|
|
if shared {
|
|
result.CodexToolCapabilities[field] = value
|
|
} else if declared {
|
|
fallback := json.RawMessage("null")
|
|
if field == "supports_search_tool" || field == "use_responses_lite" {
|
|
fallback = json.RawMessage("false")
|
|
}
|
|
result.CodexToolCapabilities[field] = fallback
|
|
}
|
|
}
|
|
for _, candidate := range candidates {
|
|
if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" {
|
|
result.DisplayName = strings.TrimSpace(candidate.DisplayName)
|
|
}
|
|
if result.Description == "" && strings.TrimSpace(candidate.Description) != "" {
|
|
result.Description = strings.TrimSpace(candidate.Description)
|
|
}
|
|
}
|
|
|
|
reasoningKnown := true
|
|
reasoningValue := false
|
|
for i, candidate := range candidates {
|
|
if candidate.Reasoning == nil {
|
|
reasoningKnown = false
|
|
break
|
|
}
|
|
if i == 0 {
|
|
reasoningValue = *candidate.Reasoning
|
|
continue
|
|
}
|
|
if reasoningValue != *candidate.Reasoning {
|
|
reasoningKnown = false
|
|
result.reasoningConflict = true
|
|
break
|
|
}
|
|
}
|
|
if reasoningKnown {
|
|
result.Reasoning = &reasoningValue
|
|
if reasoningValue {
|
|
levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels)
|
|
for _, candidate := range candidates[1:] {
|
|
levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels))
|
|
}
|
|
result.SupportedReasoningLevels = levels
|
|
if len(levels) == 0 {
|
|
result.reasoningConflict = true
|
|
} else {
|
|
sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel)
|
|
for _, candidate := range candidates[1:] {
|
|
if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault {
|
|
sharedDefault = ""
|
|
break
|
|
}
|
|
}
|
|
if !stringSliceContains(levels, sharedDefault) {
|
|
sharedDefault = levels[0]
|
|
}
|
|
result.DefaultReasoningLevel = sharedDefault
|
|
}
|
|
}
|
|
}
|
|
|
|
modalitiesKnown := true
|
|
modalities := normalizeCodexInputModalities(candidates[0].InputModalities)
|
|
if len(modalities) == 0 {
|
|
modalitiesKnown = false
|
|
}
|
|
for _, candidate := range candidates[1:] {
|
|
candidateModalities := normalizeCodexInputModalities(candidate.InputModalities)
|
|
if len(candidateModalities) == 0 {
|
|
modalitiesKnown = false
|
|
break
|
|
}
|
|
modalities = intersectOrderedStrings(modalities, candidateModalities)
|
|
}
|
|
if modalitiesKnown && len(modalities) > 0 {
|
|
result.InputModalities = modalities
|
|
} else if modalitiesKnown {
|
|
result.inputModalitiesConflict = true
|
|
}
|
|
|
|
contextKnown := true
|
|
for i, candidate := range candidates {
|
|
if candidate.ContextWindow <= 0 {
|
|
contextKnown = false
|
|
break
|
|
}
|
|
if i == 0 || candidate.ContextWindow < result.ContextWindow {
|
|
result.ContextWindow = candidate.ContextWindow
|
|
}
|
|
}
|
|
if !contextKnown {
|
|
result.ContextWindow = 0
|
|
}
|
|
return result
|
|
}
|
|
|
|
func applyUpstreamModelMetadataToCodexDescriptor(
|
|
descriptor *configuredCodexModelDescriptor,
|
|
metadata codexModelMetadataOverride,
|
|
) {
|
|
if descriptor == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(metadata.DisplayName) != "" {
|
|
descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName)
|
|
}
|
|
if strings.TrimSpace(metadata.Description) != "" {
|
|
descriptor.Description = strings.TrimSpace(metadata.Description)
|
|
}
|
|
if metadata.reasoningConflict {
|
|
descriptor.DefaultReasoningLevel = nil
|
|
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
|
} else if metadata.Reasoning != nil && !*metadata.Reasoning {
|
|
none := "none"
|
|
descriptor.DefaultReasoningLevel = &none
|
|
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{
|
|
Effort: "none",
|
|
Description: configuredCodexReasoningLevelDescription("none"),
|
|
}}
|
|
} else if metadata.Reasoning != nil && *metadata.Reasoning {
|
|
levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels)
|
|
if len(levels) == 0 {
|
|
descriptor.DefaultReasoningLevel = nil
|
|
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
|
} else {
|
|
defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel)
|
|
if !stringSliceContains(levels, defaultLevel) {
|
|
defaultLevel = levels[0]
|
|
}
|
|
descriptor.DefaultReasoningLevel = &defaultLevel
|
|
descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels))
|
|
for _, level := range levels {
|
|
descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{
|
|
Effort: level,
|
|
Description: configuredCodexReasoningLevelDescription(level),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if metadata.inputModalitiesConflict {
|
|
descriptor.InputModalities = []string{"text"}
|
|
} else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
|
|
descriptor.InputModalities = modalities
|
|
}
|
|
if metadata.ContextWindow > 0 {
|
|
descriptor.ContextWindow = metadata.ContextWindow
|
|
descriptor.MaxContextWindow = metadata.ContextWindow
|
|
}
|
|
}
|
|
|
|
func configuredCodexReasoningLevelDescription(level string) string {
|
|
switch level {
|
|
case "none":
|
|
return "Use the model's default behavior without configurable reasoning"
|
|
case "minimal":
|
|
return "Minimal reasoning for the fastest responses"
|
|
case "low":
|
|
return "Fast responses with lighter reasoning"
|
|
case "medium":
|
|
return "Balanced reasoning for most coding tasks"
|
|
case "high":
|
|
return "Greater reasoning depth for coding and agent tasks"
|
|
case "xhigh":
|
|
return "Extra-high reasoning depth for difficult tasks"
|
|
case "max":
|
|
return "Maximum reasoning depth for complex tasks"
|
|
default:
|
|
return "Reasoning effort supported by the upstream model"
|
|
}
|
|
}
|
|
|
|
func intersectOrderedStrings(left, right []string) []string {
|
|
rightSet := make(map[string]struct{}, len(right))
|
|
for _, value := range right {
|
|
rightSet[value] = struct{}{}
|
|
}
|
|
intersection := make([]string, 0, len(left))
|
|
for _, value := range left {
|
|
if _, ok := rightSet[value]; ok {
|
|
intersection = append(intersection, value)
|
|
}
|
|
}
|
|
return intersection
|
|
}
|
|
|
|
func stringSliceContains(values []string, target string) bool {
|
|
if target == "" {
|
|
return false
|
|
}
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|