Feature/llm route for aiproxy (#25044)

* feat(aiproxy): support routing model in aiproxy

* feat(llm): add llm-router
This commit is contained in:
cwz_eikoh
2026-06-22 10:09:38 +08:00
committed by GitHub
parent 50a7105cff
commit 108db8ca87
15 changed files with 769 additions and 39 deletions
+183 -3
View File
@@ -15,11 +15,17 @@
package models
import (
"bytes"
"context"
"database/sql"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/errors"
@@ -315,7 +321,7 @@ func fetchEnabledAiRoutingModels(routingId string) ([]SAiRoutingModel, error) {
}
// pickAiRoutingModel selects provider/model from ai_routing_models by request model name.
func pickAiRoutingModel(ctx context.Context, userCred mcclient.TokenCredential, routing *SAiRouting, reqModel string) (providerId, modelId string, err error) {
func pickAiRoutingModel(ctx context.Context, userCred mcclient.TokenCredential, routing *SAiRouting, reqModel string, body *jsonutils.JSONDict) (providerId, modelId string, err error) {
if routing == nil {
return "", "", errors.Wrap(httperrors.ErrInvalidStatus, "nil ai_routing")
}
@@ -326,12 +332,186 @@ func pickAiRoutingModel(ctx context.Context, userCred mcclient.TokenCredential,
if len(entries) == 0 {
return "", "", errors.Wrap(httperrors.ErrInvalidStatus, "ai_routing has no ai_routing_models")
}
var modelsById map[string]*SAiModel
if routing.RouterEnabled {
modelIds := make([]string, 0, len(entries))
for i := range entries {
modelIds = append(modelIds, entries[i].AiModelId)
}
modelsById, err = fetchEnabledAiModelsByIds(modelIds)
if err != nil {
return "", "", err
}
}
selected, err := pickAiRoutingModelFromEntries(ctx, routing, reqModel, body, entries, modelsById)
if err != nil {
return "", "", err
}
return selected.AiProviderId, selected.AiModelId, nil
}
type aiRoutingModelCandidate struct {
entry *SAiRoutingModel
modelName string
}
func pickAiRoutingModelFromEntries(
ctx context.Context,
routing *SAiRouting,
reqModel string,
body *jsonutils.JSONDict,
entries []SAiRoutingModel,
modelsById map[string]*SAiModel,
) (*SAiRoutingModel, error) {
allEntries := make([]*SAiRoutingModel, 0, len(entries))
for i := range entries {
allEntries = append(allEntries, &entries[i])
}
if len(allEntries) == 0 {
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "ai_routing has no ai_routing_models")
}
if routing != nil && routing.RouterEnabled {
candidates := buildAiRoutingModelCandidates(allEntries, modelsById)
if len(candidates) == 0 {
return routerFallbackPick(routing, allEntries[0], errors.Wrap(httperrors.ErrInvalidStatus, "router has no candidate models"))
}
candidateNames := make([]string, 0, len(candidates))
for i := range candidates {
candidateNames = append(candidateNames, candidates[i].modelName)
}
selected, err := callAiRoutingRouter(ctx, routing, body, candidateNames)
if err != nil {
return routerFallbackPick(routing, allEntries[0], err)
}
selected = strings.TrimSpace(selected)
for i := range candidates {
if strings.EqualFold(candidates[i].modelName, selected) {
return candidates[i].entry, nil
}
}
return routerFallbackPick(
routing,
allEntries[0],
errors.Wrapf(httperrors.ErrInvalidStatus, "router selected model %q outside candidates", selected),
)
}
matches := make([]*SAiRoutingModel, 0, len(entries))
for i := range entries {
e := &entries[i]
if !modelPatternMatches(e.ModelPattern, reqModel) {
continue
}
return e.AiProviderId, e.AiModelId, nil
matches = append(matches, e)
}
return "", "", errors.Wrapf(httperrors.ErrNotFound, "no ai_routing_model matched request model %q", reqModel)
if len(matches) == 0 {
return nil, errors.Wrapf(httperrors.ErrNotFound, "no ai_routing_model matched request model %q", reqModel)
}
return matches[0], nil
}
func buildAiRoutingModelCandidates(entries []*SAiRoutingModel, modelsById map[string]*SAiModel) []aiRoutingModelCandidate {
candidates := make([]aiRoutingModelCandidate, 0, len(entries))
for _, entry := range entries {
if entry == nil {
continue
}
name := clientFacingModelID(entry, modelsById[entry.AiModelId])
if name == "" {
continue
}
candidates = append(candidates, aiRoutingModelCandidate{
entry: entry,
modelName: name,
})
}
return candidates
}
func routerFallbackPick(routing *SAiRouting, first *SAiRoutingModel, err error) (*SAiRoutingModel, error) {
if routing == nil || strings.TrimSpace(routing.RouterFallbackPolicy) == "" || strings.EqualFold(routing.RouterFallbackPolicy, api.AiRoutingRouterFallbackPriority) {
return first, nil
}
return nil, err
}
func callAiRoutingRouter(ctx context.Context, routing *SAiRouting, body *jsonutils.JSONDict, candidates []string) (string, error) {
if routing == nil {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "nil ai_routing")
}
endpoint, err := aiRoutingRouterEndpoint(routing.RouterUrl, routing.RouterRoutePath)
if err != nil {
return "", err
}
payload := jsonutils.NewDict()
if body != nil {
if model, _ := body.GetString("model"); model != "" {
payload.Set("model", jsonutils.NewString(model))
}
if messages, err := body.Get("messages"); err == nil {
payload.Set("messages", messages)
}
}
payload.Set("candidates", jsonutils.Marshal(candidates))
timeout := routing.RouterTimeoutSeconds
if timeout <= 0 {
timeout = api.AiRoutingRouterDefaultTimeoutSeconds
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, endpoint, bytes.NewReader([]byte(payload.String())))
if err != nil {
return "", errors.Wrap(err, "new router request")
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errors.Wrap(err, "call ai routing router")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", errors.Errorf("router status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var out struct {
Model string `json:"model"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", errors.Wrap(err, "decode router response")
}
if strings.TrimSpace(out.Model) == "" {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "router response missing model")
}
return out.Model, nil
}
func aiRoutingRouterEndpoint(routerUrl, routePath string) (string, error) {
routerUrl = strings.TrimSpace(routerUrl)
if routerUrl == "" {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "router_url is required when router is enabled")
}
u, err := url.Parse(routerUrl)
if err != nil {
return "", errors.Wrap(err, "parse router_url")
}
if u.Scheme == "" || u.Host == "" {
return "", errors.Wrap(httperrors.ErrInputParameter, "router_url must be absolute")
}
routePath = strings.TrimSpace(routePath)
if routePath == "" {
routePath = api.AiRoutingRouterDefaultRoutePath
}
if !strings.HasPrefix(routePath, "/") {
routePath = "/" + routePath
}
basePath := strings.TrimRight(u.Path, "/")
cleanRoutePath := strings.TrimRight(routePath, "/")
if basePath == cleanRoutePath {
return u.String(), nil
}
u.Path = basePath + routePath
return u.String(), nil
}
+102 -1
View File
@@ -26,6 +26,7 @@ import (
"yunion.io/x/onecloud/pkg/apis"
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/util/stringutils2"
)
@@ -40,6 +41,13 @@ type SAiRouting struct {
ModelPattern string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
// AiProxyNodeId optionally binds the rule to one aiproxy instance (ai_proxy_node id).
AiProxyNodeId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
// Router fields optionally call an external model router before selecting an ai_routing_model.
RouterEnabled bool `default:"false" nullable:"false" list:"user" create:"optional" update:"user"`
RouterUrl string `width:"512" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
RouterRoutePath string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
RouterTimeoutSeconds int `default:"0" nullable:"false" list:"user" create:"optional" update:"user"`
RouterFallbackPolicy string `width:"32" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
}
type SAiRoutingManager struct {
@@ -77,6 +85,9 @@ func (manager *SAiRoutingManager) ListItemFilter(
if v := strings.TrimSpace(query.AiProxyNodeId); v != "" {
q = q.Equals("ai_proxy_node_id", v)
}
if query.RouterEnabled != nil {
q = q.Equals("router_enabled", *query.RouterEnabled)
}
q, err = manager.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
if err != nil {
return nil, errors.Wrap(err, "SEnabledResourceBaseManager.ListItemFilter")
@@ -96,8 +107,18 @@ func (manager *SAiRoutingManager) FetchCustomizeColumns(
sharableRows := manager.SSharableVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
routingIds := make([]string, len(objs))
for i := range objs {
routing := objs[i].(*SAiRouting)
rows[i].SharableVirtualResourceDetails = sharableRows[i]
routingIds[i] = objs[i].(*SAiRouting).Id
rows[i].Priority = routing.Priority
rows[i].ModelPattern = routing.ModelPattern
rows[i].AiProxyNodeId = routing.AiProxyNodeId
rows[i].RouterEnabled = routing.RouterEnabled
rows[i].RouterUrl = routing.RouterUrl
rows[i].RouterRoutePath = routing.RouterRoutePath
rows[i].RouterTimeoutSeconds = routing.RouterTimeoutSeconds
rows[i].RouterFallbackPolicy = routing.RouterFallbackPolicy
rows[i].Enabled = routing.GetEnabled()
routingIds[i] = routing.Id
}
if fields == nil || fields.Contains("routing_models") {
for i, rid := range routingIds {
@@ -132,6 +153,80 @@ func (routing *SAiRouting) PerformEnable(ctx context.Context, userCred mcclient.
return nil, nil
}
func normalizeAiRoutingRouterFallbackPolicy(policy string) (string, error) {
policy = strings.ToLower(strings.TrimSpace(policy))
switch policy {
case "":
return api.AiRoutingRouterFallbackPriority, nil
case api.AiRoutingRouterFallbackPriority, api.AiRoutingRouterFallbackFailClosed:
return policy, nil
default:
return "", errors.Wrapf(httperrors.ErrInputParameter, "unsupported router_fallback_policy %q", policy)
}
}
func normalizeAiRoutingRouterRoutePath(routePath string) string {
routePath = strings.TrimSpace(routePath)
if routePath == "" {
return api.AiRoutingRouterDefaultRoutePath
}
if !strings.HasPrefix(routePath, "/") {
return "/" + routePath
}
return routePath
}
func normalizeAiRoutingRouterTimeoutSeconds(timeout int) int {
if timeout <= 0 {
return api.AiRoutingRouterDefaultTimeoutSeconds
}
return timeout
}
func normalizeAiRoutingRouterCreate(input *api.AiRoutingCreateInput) error {
input.RouterUrl = strings.TrimSpace(input.RouterUrl)
input.RouterRoutePath = normalizeAiRoutingRouterRoutePath(input.RouterRoutePath)
input.RouterTimeoutSeconds = normalizeAiRoutingRouterTimeoutSeconds(input.RouterTimeoutSeconds)
policy, err := normalizeAiRoutingRouterFallbackPolicy(input.RouterFallbackPolicy)
if err != nil {
return err
}
input.RouterFallbackPolicy = policy
if input.RouterEnabled && input.RouterUrl == "" {
return errors.Wrap(httperrors.ErrInputParameter, "router_url is required when router_enabled is true")
}
return nil
}
func normalizeAiRoutingRouterUpdate(routing *SAiRouting, query jsonutils.JSONObject, input *api.AiRoutingUpdateInput) error {
effectiveEnabled := routing.RouterEnabled
if input.RouterEnabled != nil {
effectiveEnabled = *input.RouterEnabled
}
effectiveUrl := strings.TrimSpace(routing.RouterUrl)
if query.Contains("router_url") {
input.RouterUrl = strings.TrimSpace(input.RouterUrl)
effectiveUrl = input.RouterUrl
}
if query.Contains("router_route_path") {
input.RouterRoutePath = normalizeAiRoutingRouterRoutePath(input.RouterRoutePath)
}
if query.Contains("router_timeout_seconds") {
input.RouterTimeoutSeconds = normalizeAiRoutingRouterTimeoutSeconds(input.RouterTimeoutSeconds)
}
if query.Contains("router_fallback_policy") {
policy, err := normalizeAiRoutingRouterFallbackPolicy(input.RouterFallbackPolicy)
if err != nil {
return err
}
input.RouterFallbackPolicy = policy
}
if effectiveEnabled && effectiveUrl == "" {
return errors.Wrap(httperrors.ErrInputParameter, "router_url is required when router_enabled is true")
}
return nil
}
func (routing *SAiRouting) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
if err := db.EnabledPerformEnable(routing, ctx, userCred, false); err != nil {
return nil, errors.Wrap(err, "EnabledPerformEnable")
@@ -158,6 +253,9 @@ func (routing *SAiRouting) ValidateUpdateData(
} else if query.Contains("ai_proxy_node_id") {
input.AiProxyNodeId = ""
}
if err := normalizeAiRoutingRouterUpdate(routing, query, input); err != nil {
return input, err
}
return input, nil
}
@@ -184,6 +282,9 @@ func (manager *SAiRoutingManager) ValidateCreateData(
if err != nil {
return input, err
}
if err := normalizeAiRoutingRouterCreate(&input); err != nil {
return input, err
}
if input.Enabled == nil && input.Disabled == nil {
input.SetEnabled()
+2 -2
View File
@@ -125,7 +125,7 @@ func catalogProviderId(providerKey string) string {
}
func catalogProviderExists(providerId string) (bool, error) {
cnt, err := AiProviderManager.Query().Equals("id", providerId).CountWithError()
cnt, err := AiProviderManager.RawQuery().Equals("id", providerId).CountWithError()
if err != nil {
return false, errors.Wrap(err, "count catalog ai_provider")
}
@@ -133,7 +133,7 @@ func catalogProviderExists(providerId string) (bool, error) {
}
func catalogModelExists(modelId string) (bool, error) {
cnt, err := AiModelManager.Query().Equals("id", modelId).CountWithError()
cnt, err := AiModelManager.RawQuery().Equals("id", modelId).CountWithError()
if err != nil {
return false, errors.Wrap(err, "count catalog ai_model")
}
+3 -2
View File
@@ -146,11 +146,12 @@ func resolveCatalogModelFromRouting(
vk *SAiVirtualKey,
routing *SAiRouting,
reqModel string,
body *jsonutils.JSONDict,
) (*resolvedCatalogModel, error) {
if routing == nil {
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "nil ai_routing")
}
providerId, modelId, err := pickAiRoutingModel(ctx, userCred, routing, reqModel)
providerId, modelId, err := pickAiRoutingModel(ctx, userCred, routing, reqModel, body)
if err != nil {
return nil, err
}
@@ -209,7 +210,7 @@ func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential,
return nil, errors.Wrap(httperrors.ErrNotFound, "no ai_routing matched for virtual key project on this aiproxy node")
}
resolved, err := resolveCatalogModelFromRouting(ctx, userCred, vk, routing, reqModel)
resolved, err := resolveCatalogModelFromRouting(ctx, userCred, vk, routing, reqModel, body)
if err != nil {
return nil, err
}
+37 -13
View File
@@ -18,12 +18,21 @@ import (
"yunion.io/x/onecloud/pkg/apis"
)
const (
AiRoutingRouterDefaultRoutePath = "/v1/route"
AiRoutingRouterDefaultTimeoutSeconds = 3
AiRoutingRouterFallbackPriority = "priority"
AiRoutingRouterFallbackFailClosed = "fail_closed"
)
type AiRoutingListInput struct {
apis.SharableVirtualResourceListInput
apis.EnabledResourceBaseListInput
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
RouterEnabled *bool `json:"router_enabled"`
}
// AiRoutingModelItem is one catalog model binding when creating ai_routing.
@@ -41,29 +50,44 @@ type AiRoutingCreateInput struct {
apis.SharableVirtualResourceCreateInput
apis.EnabledBaseResourceCreateInput
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
Models []AiRoutingModelItem `json:"models"`
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
RouterEnabled bool `json:"router_enabled"`
RouterUrl string `json:"router_url"`
RouterRoutePath string `json:"router_route_path"`
RouterTimeoutSeconds int `json:"router_timeout_seconds"`
RouterFallbackPolicy string `json:"router_fallback_policy"`
Models []AiRoutingModelItem `json:"models"`
}
type AiRoutingUpdateInput struct {
apis.SharableVirtualResourceBaseUpdateInput
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
Enabled *bool `json:"enabled"`
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
RouterEnabled *bool `json:"router_enabled"`
RouterUrl string `json:"router_url"`
RouterRoutePath string `json:"router_route_path"`
RouterTimeoutSeconds int `json:"router_timeout_seconds"`
RouterFallbackPolicy string `json:"router_fallback_policy"`
Enabled *bool `json:"enabled"`
}
type AiRoutingDetails struct {
apis.SharableVirtualResourceDetails
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
Enabled bool `json:"enabled"`
RoutingModels []AiRoutingModelDetails `json:"routing_models,omitempty"`
Priority int `json:"priority"`
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
RouterEnabled bool `json:"router_enabled"`
RouterUrl string `json:"router_url"`
RouterRoutePath string `json:"router_route_path"`
RouterTimeoutSeconds int `json:"router_timeout_seconds"`
RouterFallbackPolicy string `json:"router_fallback_policy"`
Enabled bool `json:"enabled"`
RoutingModels []AiRoutingModelDetails `json:"routing_models,omitempty"`
}
// AiRoutingSetModelsInput replaces all ai_routing_models for an ai_routing.
+2
View File
@@ -16,6 +16,7 @@ const (
LLM_IMAGE_TYPE_COMFYUI LLMImageType = "comfyui"
LLM_IMAGE_TYPE_OPENCLAW LLMImageType = "openclaw"
LLM_IMAGE_TYPE_HERMES_AGENT LLMImageType = "hermes-agent"
LLM_IMAGE_TYPE_LLM_ROUTER LLMImageType = "llm-router"
LLM_IMAGE_TYPE_DESKTOP LLMImageType = "desktop"
)
@@ -28,6 +29,7 @@ var (
string(LLM_IMAGE_TYPE_COMFYUI),
string(LLM_IMAGE_TYPE_OPENCLAW),
string(LLM_IMAGE_TYPE_HERMES_AGENT),
string(LLM_IMAGE_TYPE_LLM_ROUTER),
string(LLM_IMAGE_TYPE_DESKTOP),
)
)
+2
View File
@@ -16,6 +16,7 @@ const (
LLM_CONTAINER_COMFYUI LLMContainerType = "comfyui"
LLM_CONTAINER_OPENCLAW LLMContainerType = "openclaw"
LLM_CONTAINER_HERMES_AGENT LLMContainerType = "hermes-agent"
LLM_CONTAINER_LLM_ROUTER LLMContainerType = "llm-router"
LLM_CONTAINER_DESKTOP LLMContainerType = "desktop"
)
@@ -28,6 +29,7 @@ var (
string(LLM_CONTAINER_COMFYUI),
string(LLM_CONTAINER_OPENCLAW),
string(LLM_CONTAINER_HERMES_AGENT),
string(LLM_CONTAINER_LLM_ROUTER),
string(LLM_CONTAINER_DESKTOP),
)
LLM_INSTANT_MODEL_TYPES = sets.NewString(
+12
View File
@@ -0,0 +1,12 @@
package llm
const (
LLM_ROUTER = "llm-router"
LLM_ROUTER_EXEC_PATH = "llm-router-runtime"
LLM_ROUTER_DEFAULT_RUNTIME = "llmrouter-lib"
LLM_ROUTER_DEFAULT_MODEL_DIR = "/models"
LLM_ROUTER_DEFAULT_ROUTE_PATH = "/v1/route"
LLM_ROUTER_DEFAULT_HEALTH_PATH = "/health"
LLM_ROUTER_DEFAULT_PORT = 8000
LLM_ROUTER_CACHE_DIR = "/root/.cache"
)
+39 -1
View File
@@ -30,6 +30,7 @@ type LLMSpec struct {
ComfyUI *LLMSpecComfyUI `json:"comfyui,omitempty"`
OpenClaw *LLMSpecOpenClaw `json:"openclaw,omitempty"`
HermesAgent *LLMSpecHermesAgent `json:"hermes_agent,omitempty"`
LLMRouter *LLMSpecLLMRouter `json:"llm_router,omitempty"`
}
func (s *LLMSpec) String() string {
@@ -40,7 +41,7 @@ func (s *LLMSpec) IsZero() bool {
if s == nil {
return true
}
return s.Ollama == nil && s.Vllm == nil && s.SGLang == nil && s.Dify == nil && s.ComfyUI == nil && s.OpenClaw == nil && s.HermesAgent == nil
return s.Ollama == nil && s.Vllm == nil && s.SGLang == nil && s.Dify == nil && s.ComfyUI == nil && s.OpenClaw == nil && s.HermesAgent == nil && s.LLMRouter == nil
}
// LLMSpecOllama holds type-specific fields for ollama SKUs.
@@ -195,6 +196,43 @@ func (s *LLMSpecHermesAgent) IsZero() bool {
return s.LLMId == "" && s.LLMUrl == "" && s.Model == "" && s.ApiKey == "" && s.ContextLength == 0
}
type LLMRouterEnv struct {
Key string `json:"key"`
Value string `json:"value"`
}
type LLMRouterArg struct {
Key string `json:"key"`
Value string `json:"value"`
}
type LLMSpecLLMRouter struct {
Runtime string `json:"runtime"`
RouterMethod string `json:"router_method"`
ConfigPath string `json:"config_path,omitempty"`
ModelDir string `json:"model_dir,omitempty"`
RoutePath string `json:"route_path,omitempty"`
HealthPath string `json:"health_path,omitempty"`
CandidateMappingPath string `json:"candidate_mapping_path,omitempty"`
CustomizedEnvs []*LLMRouterEnv `json:"customized_envs,omitempty"`
CustomizedArgs []*LLMRouterArg `json:"customized_args,omitempty"`
Extra map[string]interface{} `json:"extra,omitempty"`
}
func (s *LLMSpecLLMRouter) String() string {
return jsonutils.Marshal(s).String()
}
func (s *LLMSpecLLMRouter) IsZero() bool {
if s == nil {
return true
}
return s.Runtime == "" && s.RouterMethod == "" && s.ConfigPath == "" &&
s.ModelDir == "" && s.RoutePath == "" && s.HealthPath == "" &&
s.CandidateMappingPath == "" && len(s.CustomizedEnvs) == 0 &&
len(s.CustomizedArgs) == 0 && len(s.Extra) == 0
}
func init() {
gotypes.RegisterSerializable(reflect.TypeOf(new(LLMSpec)), func() gotypes.ISerializable {
return new(LLMSpec)
+308
View File
@@ -0,0 +1,308 @@
package llm_container
import (
"context"
"fmt"
"strings"
"yunion.io/x/pkg/errors"
commonapi "yunion.io/x/onecloud/pkg/apis"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/models"
"yunion.io/x/onecloud/pkg/mcclient"
)
func init() {
models.RegisterLLMContainerDriver(newLLMRouter())
}
type llmRouter struct {
baseDriver
}
func newLLMRouter() models.ILLMContainerDriver {
return &llmRouter{baseDriver: newBaseDriver(api.LLM_CONTAINER_LLM_ROUTER)}
}
func (r *llmRouter) GetSpec(sku *models.SLLMSku) interface{} {
if sku == nil || sku.LLMType != string(api.LLM_CONTAINER_LLM_ROUTER) || sku.LLMSpec == nil || sku.LLMSpec.LLMRouter == nil {
return nil
}
return sku.LLMSpec.LLMRouter
}
func copyLLMRouterSpec(in *api.LLMSpecLLMRouter) *api.LLMSpecLLMRouter {
if in == nil {
return nil
}
out := *in
out.Runtime = strings.TrimSpace(out.Runtime)
out.RouterMethod = strings.TrimSpace(out.RouterMethod)
out.ConfigPath = strings.TrimSpace(out.ConfigPath)
out.ModelDir = strings.TrimSpace(out.ModelDir)
out.RoutePath = strings.TrimSpace(out.RoutePath)
out.HealthPath = strings.TrimSpace(out.HealthPath)
out.CandidateMappingPath = strings.TrimSpace(out.CandidateMappingPath)
if in.CustomizedEnvs != nil {
out.CustomizedEnvs = append([]*api.LLMRouterEnv(nil), in.CustomizedEnvs...)
}
if in.CustomizedArgs != nil {
out.CustomizedArgs = append([]*api.LLMRouterArg(nil), in.CustomizedArgs...)
}
if in.Extra != nil {
out.Extra = map[string]interface{}{}
for k, v := range in.Extra {
out.Extra[k] = v
}
}
return &out
}
func mergeLLMRouterSpecs(base, override *api.LLMSpecLLMRouter) *api.LLMSpecLLMRouter {
if base == nil && override == nil {
return nil
}
out := copyLLMRouterSpec(base)
if out == nil {
out = &api.LLMSpecLLMRouter{}
}
ov := copyLLMRouterSpec(override)
if ov == nil {
return out
}
if ov.Runtime != "" {
out.Runtime = ov.Runtime
}
if ov.RouterMethod != "" {
out.RouterMethod = ov.RouterMethod
}
if ov.ConfigPath != "" {
out.ConfigPath = ov.ConfigPath
}
if ov.ModelDir != "" {
out.ModelDir = ov.ModelDir
}
if ov.RoutePath != "" {
out.RoutePath = ov.RoutePath
}
if ov.HealthPath != "" {
out.HealthPath = ov.HealthPath
}
if ov.CandidateMappingPath != "" {
out.CandidateMappingPath = ov.CandidateMappingPath
}
if ov.CustomizedEnvs != nil {
out.CustomizedEnvs = ov.CustomizedEnvs
}
if ov.CustomizedArgs != nil {
out.CustomizedArgs = ov.CustomizedArgs
}
if ov.Extra != nil {
if out.Extra == nil {
out.Extra = map[string]interface{}{}
}
for k, v := range ov.Extra {
out.Extra[k] = v
}
}
return out
}
func (r *llmRouter) normalizeSpec(in *api.LLMSpecLLMRouter) (*api.LLMSpecLLMRouter, error) {
out := copyLLMRouterSpec(in)
if out == nil {
out = &api.LLMSpecLLMRouter{}
}
out.Runtime = api.LLM_ROUTER_DEFAULT_RUNTIME
if out.ModelDir == "" {
out.ModelDir = api.LLM_ROUTER_DEFAULT_MODEL_DIR
}
if out.RoutePath == "" {
out.RoutePath = api.LLM_ROUTER_DEFAULT_ROUTE_PATH
}
if out.HealthPath == "" {
out.HealthPath = api.LLM_ROUTER_DEFAULT_HEALTH_PATH
}
if out.RouterMethod == "" {
return out, errors.Wrap(httperrors.ErrMissingParameter, "llm_router.router_method is required")
}
return out, nil
}
func (r *llmRouter) GetEffectiveSpec(llm *models.SLLM, sku *models.SLLMSku) interface{} {
var skuSpec *api.LLMSpecLLMRouter
if spec := r.GetSpec(sku); spec != nil {
skuSpec = spec.(*api.LLMSpecLLMRouter)
}
var llmSpec *api.LLMSpecLLMRouter
if llm != nil && llm.LLMSpec != nil && llm.LLMSpec.LLMRouter != nil {
llmSpec = llm.LLMSpec.LLMRouter
}
out, err := r.normalizeSpec(mergeLLMRouterSpecs(skuSpec, llmSpec))
if err != nil {
return mergeLLMRouterSpecs(skuSpec, llmSpec)
}
return out
}
func (r *llmRouter) ValidateLLMSkuCreateData(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSkuCreateInput, error) {
var err error
input, err = r.baseDriver.ValidateLLMSkuCreateData(ctx, userCred, input)
if err != nil {
return nil, err
}
spec, err := r.ValidateLLMCreateSpec(ctx, userCred, nil, input.LLMSpec)
if err != nil {
return nil, err
}
input.LLMSpec = spec
return input, nil
}
func (r *llmRouter) ValidateLLMSkuUpdateData(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSkuUpdateInput) (*api.LLMSkuUpdateInput, error) {
var err error
input, err = r.baseDriver.ValidateLLMSkuUpdateData(ctx, userCred, sku, input)
if err != nil {
return nil, err
}
if input.LLMSpec != nil {
input.LLMSpec, err = r.ValidateLLMUpdateSpec(ctx, userCred, nil, input.LLMSpec)
}
return input, err
}
func (r *llmRouter) ValidateLLMCreateSpec(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSpec) (*api.LLMSpec, error) {
var skuSpec *api.LLMSpecLLMRouter
if sku != nil && sku.LLMSpec != nil {
skuSpec = sku.LLMSpec.LLMRouter
}
var inputSpec *api.LLMSpecLLMRouter
if input != nil {
inputSpec = input.LLMRouter
}
spec, err := r.normalizeSpec(mergeLLMRouterSpecs(skuSpec, inputSpec))
if err != nil {
return input, err
}
return &api.LLMSpec{LLMRouter: spec}, nil
}
func (r *llmRouter) ValidateLLMUpdateSpec(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, input *api.LLMSpec) (*api.LLMSpec, error) {
if input == nil || input.LLMRouter == nil {
return input, nil
}
spec, err := r.normalizeSpec(input.LLMRouter)
if err != nil {
return input, err
}
return &api.LLMSpec{LLMRouter: spec}, nil
}
type llmRouterRuntimeArg struct {
Key string
Value string
}
func (a llmRouterRuntimeArg) String() string {
key := strings.TrimSpace(a.Key)
if key == "" {
return ""
}
key = strings.TrimPrefix(key, "--")
if strings.TrimSpace(a.Value) == "" {
return "--" + key
}
return fmt.Sprintf("--%s %s", key, strings.TrimSpace(a.Value))
}
func llmRouterCustomizedArgsToRuntime(args []*api.LLMRouterArg) []llmRouterRuntimeArg {
out := make([]llmRouterRuntimeArg, 0, len(args))
for _, arg := range args {
if arg == nil || strings.TrimSpace(arg.Key) == "" {
continue
}
out = append(out, llmRouterRuntimeArg{Key: arg.Key, Value: arg.Value})
}
return out
}
func buildLLMRouterEntrypointScript(spec *api.LLMSpecLLMRouter) string {
parts := []string{
api.LLM_ROUTER_EXEC_PATH,
"--host 0.0.0.0",
fmt.Sprintf("--port %d", api.LLM_ROUTER_DEFAULT_PORT),
}
return strings.Join(parts, " ")
}
func (r *llmRouter) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput {
effSpec := &api.LLMSpecLLMRouter{RouterMethod: "routerdc"}
if eff := r.GetEffectiveSpec(llm, sku); eff != nil {
effSpec = eff.(*api.LLMSpecLLMRouter)
}
envs := []*commonapi.ContainerKeyValue{
models.NewEnv("LLM_ROUTER_RUNTIME", effSpec.Runtime),
models.NewEnv("LLM_ROUTER_METHOD", effSpec.RouterMethod),
models.NewEnv("LLM_ROUTER_MODEL_DIR", effSpec.ModelDir),
models.NewEnv("LLM_ROUTER_ROUTE_PATH", effSpec.RoutePath),
models.NewEnv("LLM_ROUTER_HEALTH_PATH", effSpec.HealthPath),
}
if effSpec.ConfigPath != "" {
envs = append(envs, models.NewEnv("LLM_ROUTER_CONFIG", effSpec.ConfigPath))
}
if effSpec.CandidateMappingPath != "" {
envs = append(envs, models.NewEnv("LLM_ROUTER_CANDIDATE_MAPPING", effSpec.CandidateMappingPath))
}
for _, env := range effSpec.CustomizedEnvs {
if env != nil && strings.TrimSpace(env.Key) != "" {
envs = append(envs, models.NewEnv(strings.TrimSpace(env.Key), env.Value))
}
}
spec := computeapi.ContainerSpec{
ContainerSpec: commonapi.ContainerSpec{
Image: image.ToContainerImage(),
ImageCredentialId: image.CredentialId,
Command: []string{"/bin/sh", "-c"},
Args: []string{buildLLMRouterEntrypointScript(effSpec)},
EnableLxcfs: true,
AlwaysRestart: true,
Envs: envs,
},
}
appendContainerIsolatedDevices(&spec, llm, sku, devices)
diskIndex := 0
spec.VolumeMounts = append(spec.VolumeMounts,
&commonapi.ContainerVolumeMount{
Disk: &commonapi.ContainerVolumeMountDisk{
SubDirectory: api.LLM_ROUTER,
Index: &diskIndex,
},
Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK,
MountPath: effSpec.ModelDir,
ReadOnly: false,
},
&commonapi.ContainerVolumeMount{
Disk: &commonapi.ContainerVolumeMountDisk{
SubDirectory: "cache",
Index: &diskIndex,
},
Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK,
MountPath: api.LLM_ROUTER_CACHE_DIR,
ReadOnly: false,
},
)
return &computeapi.PodContainerCreateInput{ContainerSpec: spec}
}
func (r *llmRouter) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput {
return []*computeapi.PodContainerCreateInput{
r.GetContainerSpec(ctx, llm, image, sku, props, devices, diskId),
}
}
func (r *llmRouter) GetLLMAccessUrlInfo(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, input *models.LLMAccessInfoInput) (*api.LLMAccessUrlInfo, error) {
return models.GetLLMAccessUrlInfo(ctx, userCred, llm, input, "http", api.LLM_ROUTER_DEFAULT_PORT)
}
+4
View File
@@ -986,6 +986,10 @@ func (llm *SLLM) GetLLMAccessUrlInfo(ctx context.Context, userCred mcclient.Toke
return llm.GetLLMContainerDriver().GetLLMAccessUrlInfo(ctx, userCred, llm, input)
}
func (llm *SLLM) GetDetailsUrl(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (*api.LLMAccessUrlInfo, error) {
return llm.GetLLMAccessUrlInfo(ctx, userCred, query)
}
func GetLLMAccessUrlInfo(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM, input *LLMAccessInfoInput, protocol string, defaultPort int) (*api.LLMAccessUrlInfo, error) {
port := defaultPort
accessUrl := input.ServerIp
+22 -11
View File
@@ -283,6 +283,7 @@ type AiRoutingListOptions struct {
ModelPattern string `json:"model_pattern"`
AiProxyNodeId string `json:"ai_proxy_node_id"`
RouterEnabled *bool `help:"filter by router enabled flag" json:"router_enabled"`
Enabled *bool `help:"filter by enabled flag" json:"enabled"`
}
@@ -297,12 +298,17 @@ type AiRoutingShowOptions struct {
type AiRoutingCreateOptions struct {
apis.SharableVirtualResourceCreateInput
Priority int `json:"priority,omitzero"`
ModelPattern string `json:"model_pattern,omitempty"`
AiProxyNodeId string `json:"ai_proxy_node_id,omitempty"`
Models string `help:"routing models JSON array: [{ai_provider_id,ai_model_id,priority|weight,model_pattern?}]" json:"-"`
Enabled *bool `help:"turn on enabled flag" json:"enabled,omitempty"`
Disabled *bool `help:"turn off enabled flag" json:"disabled,omitempty"`
Priority int `json:"priority,omitzero"`
ModelPattern string `json:"model_pattern,omitempty"`
AiProxyNodeId string `json:"ai_proxy_node_id,omitempty"`
RouterEnabled *bool `help:"enable external model router" json:"router_enabled,omitempty"`
RouterUrl string `help:"external router base URL, e.g. http://192.168.6.60:20000" json:"router_url,omitempty"`
RouterRoutePath string `help:"external router route path, default /v1/route" json:"router_route_path,omitempty"`
RouterTimeoutSeconds int `help:"external router timeout seconds, default 3" json:"router_timeout_seconds,omitzero"`
RouterFallbackPolicy string `help:"router failure policy: priority or fail_closed" json:"router_fallback_policy,omitempty"`
Models string `help:"routing models JSON array: [{ai_provider_id,ai_model_id,priority|weight,model_pattern?}]" json:"-"`
Enabled *bool `help:"turn on enabled flag" json:"enabled,omitempty"`
Disabled *bool `help:"turn off enabled flag" json:"disabled,omitempty"`
}
func (o *AiRoutingCreateOptions) Params() (jsonutils.JSONObject, error) {
@@ -316,11 +322,16 @@ func (o *AiRoutingCreateOptions) Params() (jsonutils.JSONObject, error) {
type AiRoutingUpdateOptions struct {
apis.SharableVirtualResourceBaseUpdateInput
ID string `help:"ID or name" json:"-"`
Priority int `json:"priority,omitzero"`
ModelPattern string `json:"model_pattern,omitempty"`
AiProxyNodeId string `json:"ai_proxy_node_id,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
ID string `help:"ID or name" json:"-"`
Priority int `json:"priority,omitzero"`
ModelPattern string `json:"model_pattern,omitempty"`
AiProxyNodeId string `json:"ai_proxy_node_id,omitempty"`
RouterEnabled *bool `help:"enable or disable external model router" json:"router_enabled,omitempty"`
RouterUrl string `help:"external router base URL" json:"router_url,omitempty"`
RouterRoutePath string `help:"external router route path" json:"router_route_path,omitempty"`
RouterTimeoutSeconds int `help:"external router timeout seconds" json:"router_timeout_seconds,omitzero"`
RouterFallbackPolicy string `help:"router failure policy: priority or fail_closed" json:"router_fallback_policy,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (o *AiRoutingUpdateOptions) GetId() string {
+3 -3
View File
@@ -18,7 +18,7 @@ func (o *LLMImageShowOptions) Params() (jsonutils.JSONObject, error) {
type LLMImageListOptions struct {
options.BaseListOptions
LLMType string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|desktop" help:"filter by llm type"`
LLMType string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|llm-router|desktop" help:"filter by llm type"`
}
func (o *LLMImageListOptions) Params() (jsonutils.JSONObject, error) {
@@ -30,7 +30,7 @@ type LLMImageCreateOptions struct {
IMAGE_NAME string `json:"image_name"`
IMAGE_LABEL string `json:"image_label"`
CredentialId string `json:"credential_id"`
LLM_TYPE string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|desktop" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, desktop or dify"`
LLM_TYPE string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|llm-router|desktop" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, llm-router, desktop or dify"`
}
func (o *LLMImageCreateOptions) Params() (jsonutils.JSONObject, error) {
@@ -44,7 +44,7 @@ type LLMImageUpdateOptions struct {
ImageName string `json:"image_name"`
ImageLabel string `json:"image_label"`
CredentialId string `json:"credential_id"`
LlmType string `json:"llm_type" choices:"ollama|dify|vllm|sglang|comfyui|hermes-agent|desktop" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, desktop or dify"`
LlmType string `json:"llm_type" choices:"ollama|dify|vllm|sglang|comfyui|hermes-agent|llm-router|desktop" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, llm-router, desktop or dify"`
AppName string `json:"app_name" help:"desktop application identifier (LinuxServer image id), e.g. firefox, chromium, steam, webtop-ubuntu-xfce"`
}
+1 -1
View File
@@ -47,7 +47,7 @@ type LLMDeploymentCreateOptions struct {
// Mode B/C: SKU spec (all sku-* flags collapse into sku_spec)
SkuName string `help:"SKU name (default: <deploy-name>-sku)" json:"-"`
SkuLLMImageId string `help:"container image id (for SkuSpec)" json:"-"`
SkuLLMType string `help:"container llm type" choices:"ollama|vllm|comfyui|sglang|dify" json:"-"`
SkuLLMType string `help:"container llm type" choices:"ollama|vllm|comfyui|sglang|dify|llm-router" json:"-"`
SkuCpu int `help:"SKU CPU cores" json:"-"`
SkuMemory int `help:"SKU memory MB" json:"-"`
SkuDiskSize int `help:"SKU disk size MB" json:"-"`
+49 -2
View File
@@ -13,7 +13,7 @@ import (
type LLMSkuListOptions struct {
options.BaseListOptions
LLMType string `json:"llm_type" choices:"ollama|vllm|sglang|dify|comfyui|openclaw|hermes-agent|desktop"`
LLMType string `json:"llm_type" choices:"ollama|vllm|sglang|dify|comfyui|openclaw|hermes-agent|llm-router|desktop"`
Source string `json:"source" help:"filter by source (huggingface, model_scope, local_path)"`
Categories string `json:"categories" help:"filter by category (llm, embedding, image, ...)"`
}
@@ -36,7 +36,7 @@ type LLMSkuCreateOptions struct {
MountedModels []string `help:"mounted models, <model_id> e.g. qwen2:0.5b-dup" json:"mounted_models"`
LLM_IMAGE_ID string `json:"llm_image_id"`
LLM_TYPE string `json:"llm_type" choices:"ollama|vllm|sglang|comfyui|hermes-agent|desktop"`
LLM_TYPE string `json:"llm_type" choices:"ollama|vllm|sglang|comfyui|hermes-agent|llm-router|desktop"`
// Model source
Source string `help:"model source: huggingface, model_scope, local_path" json:"source"`
@@ -67,6 +67,14 @@ type LLMSkuCreateOptions struct {
HermesModel string `token:"hermes-model" help:"model name for Hermes" json:"-"`
HermesApiKey string `token:"hermes-api-key" help:"API key for Hermes custom provider; defaults to EMPTY when omitted" json:"-"`
HermesContextLength int `token:"hermes-context-length" help:"Hermes model.context_length" json:"-"`
RouterMethod string `token:"router-method" help:"LLM router method, e.g. routerdc" json:"-"`
RouterConfigPath string `token:"router-config-path" help:"LLM router config path inside container" json:"-"`
RouterModelDir string `token:"router-model-dir" help:"LLM router model directory inside container" json:"-"`
RouterRoutePath string `token:"router-route-path" help:"LLM router route API path, e.g. /v1/route" json:"-"`
RouterHealthPath string `token:"router-health-path" help:"LLM router health API path, e.g. /health" json:"-"`
RouterCandidateMappingPath string `token:"router-candidate-mapping-path" help:"LLM router candidate mapping path inside container" json:"-"`
RouterEnv []string `token:"router-env" help:"LLM router env in format key=value; repeatable" json:"-"`
}
func (o *LLMSkuCreateOptions) Params() (jsonutils.JSONObject, error) {
@@ -126,10 +134,49 @@ func (o *LLMSkuCreateOptions) Params() (jsonutils.JSONObject, error) {
spec := &api.LLMSpec{HermesAgent: hermesSpec}
dict.Set("llm_spec", jsonutils.Marshal(spec))
}
case string(api.LLM_CONTAINER_LLM_ROUTER):
routerSpec, err := newLLMRouterSpecFromArgs(o)
if err != nil {
return nil, err
}
dict.Set("llm_spec", jsonutils.Marshal(&api.LLMSpec{LLMRouter: routerSpec}))
}
return dict, nil
}
func newLLMRouterSpecFromArgs(o *LLMSkuCreateOptions) (*api.LLMSpecLLMRouter, error) {
method := strings.TrimSpace(o.RouterMethod)
if method == "" {
return nil, errors.Error("--router-method is required when llm_type=llm-router")
}
envs, err := newLLMRouterEnvsFromArgs(o.RouterEnv)
if err != nil {
return nil, err
}
return &api.LLMSpecLLMRouter{
RouterMethod: method,
ConfigPath: strings.TrimSpace(o.RouterConfigPath),
ModelDir: strings.TrimSpace(o.RouterModelDir),
RoutePath: strings.TrimSpace(o.RouterRoutePath),
HealthPath: strings.TrimSpace(o.RouterHealthPath),
CandidateMappingPath: strings.TrimSpace(o.RouterCandidateMappingPath),
CustomizedEnvs: envs,
}, nil
}
func newLLMRouterEnvsFromArgs(args []string) ([]*api.LLMRouterEnv, error) {
envs := make([]*api.LLMRouterEnv, 0, len(args))
for _, arg := range args {
key, val, ok := strings.Cut(arg, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, errors.Errorf("invalid --router-env %q, expected key=value", arg)
}
envs = append(envs, &api.LLMRouterEnv{Key: key, Value: strings.TrimSpace(val)})
}
return envs, nil
}
func (o *LLMSkuCreateOptions) buildModelSpec() (*api.InstantModelImportInput, error) {
if o.ModelTag == "" {
return nil, errors.Error("--model-tag is required for model spec")