feat(llm): add llm-router-agent (#25128)

This commit is contained in:
cwz_eikoh
2026-07-15 15:31:08 +08:00
committed by GitHub
parent 20620bb5a1
commit ed4a8dc049
19 changed files with 1275 additions and 424 deletions
+66
View File
@@ -0,0 +1,66 @@
package llm
import (
"fmt"
"io"
"net/http"
"strings"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/cmd/climc/shell"
"yunion.io/x/onecloud/pkg/mcclient"
modules "yunion.io/x/onecloud/pkg/mcclient/modules/llm"
options "yunion.io/x/onecloud/pkg/mcclient/options/llm"
)
func init() {
cmd := shell.NewResourceCmd(&modules.LLMRouterAgent)
cmd.List(new(options.LLMRouterAgentListOptions))
cmd.Show(new(options.LLMRouterAgentShowOptions))
cmd.Create(new(options.LLMRouterAgentCreateOptions))
cmd.Update(new(options.LLMRouterAgentUpdateOptions))
cmd.Delete(new(options.LLMRouterAgentDeleteOptions))
shell.R(&options.LLMRouterAgentRouteOptions{}, "llm-router-agent-route", "Route llm_router_agent", routeLLMRouterAgent)
}
func routeLLMRouterAgent(s *mcclient.ClientSession, args *options.LLMRouterAgentRouteOptions) error {
id := args.ID
if id != "default" {
var err error
id, err = modules.LLMRouterAgent.GetId(s, args.ID, nil)
if err != nil {
return err
}
}
bodyJSON, err := args.Params()
if err != nil {
return err
}
headers := http.Header{}
headers.Set("Content-Type", "application/json")
resp, err := s.RawVersionRequest(
modules.LLMRouterAgent.ServiceType(),
modules.LLMRouterAgent.EndpointType(),
"POST",
fmt.Sprintf("/llm_router_agents/%s/route", id),
headers,
strings.NewReader(bodyJSON.String()),
)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Error: %s %s", resp.Status, string(respBody))
}
obj, err := jsonutils.Parse(respBody)
if err != nil {
return err
}
shell.PrintObject(obj)
return nil
}
+2 -1
View File
@@ -30,6 +30,7 @@ import (
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/errors"
"yunion.io/x/pkg/tristate"
"yunion.io/x/pkg/util/httputils"
"yunion.io/x/sqlchemy"
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
@@ -629,7 +630,7 @@ func callAiRoutingRouter(ctx context.Context, routing *SAiRouting, routerModel s
return nil, errors.Wrap(err, "new router request")
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := httputils.GetTimeoutClient(time.Duration(timeout) * time.Second).Do(req)
if err != nil {
return nil, errors.Wrap(err, "call ai routing router")
}
-2
View File
@@ -16,7 +16,6 @@ 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"
LLM_IMAGE_TYPE_BENCHMARK LLMImageType = "benchmark"
)
@@ -30,7 +29,6 @@ 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),
string(LLM_IMAGE_TYPE_BENCHMARK),
)
+2
View File
@@ -4,6 +4,8 @@ const (
STATUS_READY = "ready"
)
const LLM_DEFAULT_CONTEXT_TOKENS = 8192
const (
/* 未知 */
LLM_STATUS_UNKNOWN = "unknown"
-2
View File
@@ -16,7 +16,6 @@ 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"
)
@@ -29,7 +28,6 @@ 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
@@ -1,12 +0,0 @@
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"
)
+98
View File
@@ -0,0 +1,98 @@
package llm
import "yunion.io/x/onecloud/pkg/apis"
const (
LLM_ROUTER_ROUTE_SIMPLE = "simple"
LLM_ROUTER_ROUTE_COMPLEX = "complex"
LLM_ROUTER_DEFAULT_ROUTE = LLM_ROUTER_ROUTE_COMPLEX
LLM_ROUTER_DEFAULT_MAX_PROMPT_CHARS = LLM_DEFAULT_CONTEXT_TOKENS * 3 / 4
LLM_ROUTER_DEFAULT_MAX_DECISION_TOKENS = 64
LLM_ROUTER_DEFAULT_SIMPLE_DEFINITION = "适合翻译、摘要、短问答和格式转换。"
LLM_ROUTER_DEFAULT_COMPLEX_DEFINITION = "适合代码、数学、长上下文、复杂推理和日志分析。"
)
type LLMRouterAgentListInput struct {
apis.SharableVirtualResourceListInput
LLMDriver string `json:"llm_driver"`
}
type LLMRouterAgentCreateInput struct {
apis.SharableVirtualResourceCreateInput
LLMId string `json:"llm_id" help:"LLM instance ID; if set, llm_url is resolved from it"`
LLMUrl string `json:"llm_url" help:"decision model OpenAI-compatible base URL"`
LLMDriver string `json:"llm_driver" help:"decision model driver, default openai"`
Model string `json:"model" help:"decision model name"`
ApiKey string `json:"api_key" help:"decision model API key"`
DefaultRoute string `json:"default_route" help:"fallback route, default complex"`
MaxPromptChars int `json:"max_prompt_chars" help:"request char limit; 0 auto-detects /v1/models context, longer requests use complex"`
MaxDecisionTokens int `json:"max_decision_tokens" help:"decision answer token hint"`
CandidateMapping map[string][]string `json:"candidate_mapping" help:"route to candidate model names"`
SimpleDefinition string `json:"simple_definition" help:"definition of simple requests"`
ComplexDefinition string `json:"complex_definition" help:"definition of complex requests"`
SimpleExamples []string `json:"simple_examples" help:"examples routed to simple"`
ComplexExamples []string `json:"complex_examples" help:"examples routed to complex"`
}
type LLMRouterAgentUpdateInput struct {
apis.SharableVirtualResourceBaseUpdateInput
LLMId *string `json:"llm_id,omitempty" help:"LLM instance ID; if set, llm_url is resolved from it"`
LLMUrl *string `json:"llm_url,omitempty" help:"decision model OpenAI-compatible base URL"`
LLMDriver *string `json:"llm_driver,omitempty" help:"decision model driver"`
Model *string `json:"model,omitempty" help:"decision model name"`
ApiKey *string `json:"api_key,omitempty" help:"decision model API key"`
DefaultRoute *string `json:"default_route,omitempty" help:"fallback route"`
MaxPromptChars *int `json:"max_prompt_chars,omitempty" help:"request char limit; 0 recomputes from /v1/models context"`
MaxDecisionTokens *int `json:"max_decision_tokens,omitempty" help:"decision answer token hint"`
CandidateMapping *map[string][]string `json:"candidate_mapping,omitempty" help:"route to candidate model names"`
SimpleDefinition *string `json:"simple_definition,omitempty" help:"definition of simple requests"`
ComplexDefinition *string `json:"complex_definition,omitempty" help:"definition of complex requests"`
SimpleExamples *[]string `json:"simple_examples,omitempty" help:"replace examples routed to simple"`
ComplexExamples *[]string `json:"complex_examples,omitempty" help:"replace examples routed to complex"`
}
type LLMRouterAgentDetails struct {
apis.SharableVirtualResourceDetails
LLMId string `json:"llm_id"`
LLMName string `json:"llm_name"`
}
type LLMRouterPromptConfig struct {
SimpleDefinition string `json:"simple_definition"`
ComplexDefinition string `json:"complex_definition"`
SimpleExamples []string `json:"simple_examples"`
ComplexExamples []string `json:"complex_examples"`
}
type LLMRouterExpandExamplesInput struct {
SimpleDefinition *string `json:"simple_definition,omitempty"`
ComplexDefinition *string `json:"complex_definition,omitempty"`
SimpleExamples *[]string `json:"simple_examples,omitempty"`
ComplexExamples *[]string `json:"complex_examples,omitempty"`
}
type LLMRouterMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
type LLMRouterRouteRequest struct {
Model string `json:"model"`
Messages []LLMRouterMessage `json:"messages"`
Candidates []string `json:"candidates"`
}
type LLMRouterRouteResponse struct {
Model string `json:"model"`
}
type LLMRouterDecision struct {
Route string `json:"route"`
Confidence float64 `json:"confidence"`
Reason string `json:"reason,omitempty"`
}
+1 -39
View File
@@ -30,7 +30,6 @@ 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 {
@@ -41,7 +40,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 && s.LLMRouter == nil
return s.Ollama == nil && s.Vllm == nil && s.SGLang == nil && s.Dify == nil && s.ComfyUI == nil && s.OpenClaw == nil && s.HermesAgent == nil
}
// LLMSpecOllama holds type-specific fields for ollama SKUs.
@@ -196,43 +195,6 @@ 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
@@ -1,308 +0,0 @@
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)
}
+7
View File
@@ -914,6 +914,13 @@ func (llm *SLLM) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSO
if cnt > 0 {
return httperrors.NewConflictError("LLM is being used by %d MCPAgents", cnt)
}
cnt, err = GetLLMRouterAgentManager().Query().Equals("llm_id", llm.Id).CountWithError()
if err != nil {
return errors.Wrap(err, "GetLLMRouterAgentManager().Query().CountWithError")
}
if cnt > 0 {
return httperrors.NewConflictError("LLM is being used by %d LLMRouterAgents", cnt)
}
return nil
}
+2 -1
View File
@@ -53,7 +53,8 @@ func getDriver[K ~string, D any](drvs *drivers, typ K) D {
func getDriverWithError[K ~string, D any](drvs *drivers, typ K) (D, error) {
drv, err := drvs.GetWithError(string(typ))
if err != nil {
return drv.(D), err
var zero D
return zero, err
}
return drv.(D), nil
}
+1 -6
View File
@@ -23,11 +23,6 @@ const (
autoGpuMemoryUtilizationMin = 0.05
autoGpuMemoryUtilizationMax = 0.95
// vLLM derives an unset max-model-len from model config. When auto GPU
// memory utilization is enabled, inject a conservative cap so the heuristic
// VRAM estimate is not paired with an unexpectedly large context.
autoGpuMemoryUtilizationDefaultContextTokens = int64(8192)
sglangAutoGpuMemoryMetadataReserveMB = 512
)
@@ -465,7 +460,7 @@ func buildAutoGpuMemoryUtilizationLLMSpec(sku *SLLMSku, utilization float64) (*a
if spec != nil && spec.Vllm != nil && !runtimeHasExplicitTokenLimit(sku) {
spec.Vllm.CustomizedArgs = append(spec.Vllm.CustomizedArgs, &api.VllmCustomizedArg{
Key: "max-model-len",
Value: strconv.FormatInt(autoGpuMemoryUtilizationDefaultContextTokens, 10),
Value: strconv.FormatInt(api.LLM_DEFAULT_CONTEXT_TOKENS, 10),
})
}
}
+825
View File
@@ -0,0 +1,825 @@
package models
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
seclib "yunion.io/x/pkg/utils"
"yunion.io/x/sqlchemy"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/options"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/util/stringutils2"
)
func init() {
GetLLMRouterAgentManager()
}
var llmRouterAgentManager *SLLMRouterAgentManager
func GetLLMRouterAgentManager() *SLLMRouterAgentManager {
if llmRouterAgentManager != nil {
return llmRouterAgentManager
}
llmRouterAgentManager = &SLLMRouterAgentManager{
SSharableVirtualResourceBaseManager: db.NewSharableVirtualResourceBaseManager(
SLLMRouterAgent{},
"llm_router_agents_tbl",
"llm_router_agent",
"llm_router_agents",
),
}
llmRouterAgentManager.SetVirtualObject(llmRouterAgentManager)
return llmRouterAgentManager
}
type SLLMRouterAgentManager struct {
db.SSharableVirtualResourceBaseManager
}
type SLLMRouterAgent struct {
db.SSharableVirtualResourceBase
LLMId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
LLMUrl string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"required" update:"user"`
LLMDriver string `width:"64" charset:"ascii" nullable:"false" list:"user" create:"optional" update:"user"`
Model string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
ApiKey string `width:"512" charset:"utf8" nullable:"true" create:"optional" update:"user"`
DefaultRoute string `width:"32" charset:"ascii" nullable:"false" default:"complex" list:"user" create:"optional" update:"user"`
MaxPromptChars int `nullable:"false" default:"6144" list:"user" create:"optional" update:"user"`
MaxDecisionTokens int `nullable:"false" default:"64" list:"user" create:"optional" update:"user"`
CandidateMapping jsonutils.JSONObject `length:"long" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
SimpleDefinition string `length:"long" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
ComplexDefinition string `length:"long" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
SimpleExamples jsonutils.JSONObject `length:"long" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
ComplexExamples jsonutils.JSONObject `length:"long" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
}
func (agent *SLLMRouterAgent) BeforeInsert() {
if len(agent.Id) == 0 {
agent.Id = db.DefaultUUIDGenerator()
}
agent.encryptApiKey()
agent.SSharableVirtualResourceBase.BeforeInsert()
}
func (agent *SLLMRouterAgent) BeforeUpdate() {
agent.encryptApiKey()
}
func (agent *SLLMRouterAgent) encryptApiKey() {
if len(agent.ApiKey) == 0 {
return
}
if _, err := seclib.DescryptAESBase64(agent.Id, agent.ApiKey); err == nil {
return
}
sec, err := seclib.EncryptAESBase64(agent.Id, agent.ApiKey)
if err != nil {
log.Errorf("EncryptAESBase64 fail %s", err)
return
}
agent.ApiKey = sec
}
func (agent *SLLMRouterAgent) GetApiKey() (string, error) {
if len(agent.ApiKey) == 0 {
return "", nil
}
key, err := seclib.DescryptAESBase64(agent.Id, agent.ApiKey)
if err == nil {
return key, nil
}
return "", errors.Wrap(err, "decrypt llm router api key")
}
func (man *SLLMRouterAgentManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.LLMRouterAgentCreateInput) (*api.LLMRouterAgentCreateInput, error) {
var err error
input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput)
if err != nil {
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
}
if err := resolveRouterAgentLLM(ctx, userCred, query, &input.LLMId, &input.LLMUrl, &input.Model); err != nil {
return input, err
}
input.LLMDriver = normalizeRouterLLMDriver(input.LLMDriver)
if !api.IsLLMClientType(input.LLMDriver) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), input.LLMDriver)
}
input.LLMUrl = strings.TrimSpace(input.LLMUrl)
input.Model = strings.TrimSpace(input.Model)
if input.LLMUrl == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required (or provide llm_id to auto-fetch)")
}
if input.Model == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "model is required")
}
input.DefaultRoute = normalizeRouterRoute(input.DefaultRoute)
input.MaxPromptChars, err = resolveRouterMaxPromptChars(ctx, input.MaxPromptChars, input.LLMUrl, input.Model, input.ApiKey)
if err != nil {
return input, err
}
input.MaxDecisionTokens = normalizePositiveInt(input.MaxDecisionTokens, api.LLM_ROUTER_DEFAULT_MAX_DECISION_TOKENS)
input.CandidateMapping = normalizeCandidateMapping(input.CandidateMapping)
promptConfig := normalizeRouterPromptConfig(api.LLMRouterPromptConfig{
SimpleDefinition: input.SimpleDefinition,
ComplexDefinition: input.ComplexDefinition,
SimpleExamples: input.SimpleExamples,
ComplexExamples: input.ComplexExamples,
})
input.SimpleDefinition = promptConfig.SimpleDefinition
input.ComplexDefinition = promptConfig.ComplexDefinition
input.SimpleExamples = promptConfig.SimpleExamples
input.ComplexExamples = promptConfig.ComplexExamples
input.Status = api.STATUS_READY
return input, nil
}
func (agent *SLLMRouterAgent) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.LLMRouterAgentUpdateInput) (api.LLMRouterAgentUpdateInput, error) {
var err error
input.SharableVirtualResourceBaseUpdateInput, err = agent.SSharableVirtualResourceBase.ValidateUpdateData(ctx, userCred, query, input.SharableVirtualResourceBaseUpdateInput)
if err != nil {
return input, errors.Wrap(err, "validate SharableVirtualResourceBaseUpdateInput")
}
if input.LLMId != nil && strings.TrimSpace(*input.LLMId) != "" {
llmId := strings.TrimSpace(*input.LLMId)
llmUrl := ""
model := ""
if input.LLMUrl != nil {
llmUrl = *input.LLMUrl
}
if input.Model != nil {
model = *input.Model
}
if err := resolveRouterAgentLLM(ctx, userCred, query, &llmId, &llmUrl, &model); err != nil {
return input, err
}
input.LLMId = &llmId
input.LLMUrl = &llmUrl
input.Model = &model
}
if input.LLMDriver != nil {
driver := normalizeRouterLLMDriver(*input.LLMDriver)
if !api.IsLLMClientType(driver) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), driver)
}
input.LLMDriver = &driver
}
if input.LLMUrl != nil {
llmUrl := strings.TrimSpace(*input.LLMUrl)
if llmUrl == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url cannot be empty")
}
input.LLMUrl = &llmUrl
}
if input.Model != nil {
model := strings.TrimSpace(*input.Model)
if model == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "model cannot be empty")
}
input.Model = &model
}
if input.DefaultRoute != nil {
route := normalizeRouterRoute(*input.DefaultRoute)
input.DefaultRoute = &route
}
if err := agent.normalizeRouterUpdateMaxPromptChars(ctx, &input); err != nil {
return input, err
}
if input.MaxDecisionTokens != nil && *input.MaxDecisionTokens <= 0 {
val := api.LLM_ROUTER_DEFAULT_MAX_DECISION_TOKENS
input.MaxDecisionTokens = &val
}
if input.CandidateMapping != nil {
mapping := normalizeCandidateMapping(*input.CandidateMapping)
input.CandidateMapping = &mapping
}
normalizeRouterPromptUpdate(&input)
return input, nil
}
func resolveRouterAgentLLM(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, llmId *string, llmUrl *string, model *string) error {
if llmId == nil || strings.TrimSpace(*llmId) == "" {
return nil
}
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, strings.TrimSpace(*llmId))
if err != nil {
return errors.Wrapf(err, "fetch LLM by id %s", *llmId)
}
llm := llmObj.(*SLLM)
*llmId = llm.Id
info, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
if err != nil {
return errors.Wrapf(err, "get LLM URL from LLM %s", *llmId)
}
*llmUrl = info.LoginUrl
if strings.TrimSpace(*model) != "" {
return nil
}
mdlInfos, err := llm.getProbedInstantModelsExt(ctx, userCred)
if err != nil {
return errors.Wrap(err, "get probed models from LLM instance")
}
if len(mdlInfos) == 0 {
return httperrors.NewBadRequestError("no available models found in LLM instance %s", *llmId)
}
var first api.LLMInternalInstantMdlInfo
for _, mdlInfo := range mdlInfos {
first = mdlInfo
break
}
*model = fmt.Sprintf("%s:%s", first.Name, first.Tag)
return nil
}
func normalizeRouterLLMDriver(driver string) string {
driver = strings.ToLower(strings.TrimSpace(driver))
if driver == "" {
return string(api.LLM_CLIENT_OPENAI)
}
return driver
}
func normalizeRouterRoute(route string) string {
route = strings.ToLower(strings.TrimSpace(route))
if route == "" {
return api.LLM_ROUTER_DEFAULT_ROUTE
}
return route
}
func normalizePositiveInt(val int, def int) int {
if val <= 0 {
return def
}
return val
}
const routerModelsTimeout = 3 * time.Second
type routerModelsResponse struct {
Data []routerModelEntry `json:"data"`
}
type routerModelEntry struct {
ID string `json:"id"`
MaxModelLen int `json:"max_model_len"`
ContextLength int `json:"context_length"`
}
func buildRouterModelsURL(endpoint string) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", errors.Error("llm_url is empty")
}
u, err := url.Parse(endpoint)
if err != nil {
return "", errors.Wrap(err, "parse llm_url")
}
if u.Scheme == "" || u.Host == "" {
return "", errors.Error("llm_url must be absolute")
}
u.RawQuery = ""
u.Fragment = ""
path := strings.TrimRight(u.Path, "/")
switch {
case path == "":
u.Path = "/v1/models"
case strings.HasSuffix(path, "/v1/models"):
u.Path = path
case strings.HasSuffix(path, "/v1"):
u.Path = path + "/models"
default:
u.Path = path + "/v1/models"
}
return u.String(), nil
}
func fetchRouterContextWindow(ctx context.Context, client *http.Client, endpoint, model, apiKey string) (int, error) {
modelsURL, err := buildRouterModelsURL(endpoint)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
if err != nil {
return 0, errors.Wrap(err, "create /v1/models request")
}
req.Header.Set("Accept", "application/json")
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return 0, errors.Wrap(err, "request /v1/models")
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return 0, errors.Errorf("/v1/models returned status %d", resp.StatusCode)
}
var out routerModelsResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
return 0, errors.Wrap(err, "decode /v1/models response")
}
model = strings.TrimSpace(model)
for _, item := range out.Data {
if strings.TrimSpace(item.ID) != model {
continue
}
if item.MaxModelLen > 0 {
return item.MaxModelLen, nil
}
if item.ContextLength > 0 {
return item.ContextLength, nil
}
return 0, errors.Errorf("model %q has no valid context length", model)
}
return 0, errors.Errorf("model %q not found in /v1/models", model)
}
func routerMaxPromptChars(contextTokens int) int {
if contextTokens <= 0 {
contextTokens = api.LLM_DEFAULT_CONTEXT_TOKENS
}
return contextTokens * 3 / 4
}
func resolveRouterMaxPromptChars(ctx context.Context, requested int, endpoint, model, apiKey string) (int, error) {
if requested < 0 {
return 0, errors.Wrapf(httperrors.ErrInputParameter, "max_prompt_chars must be >= 0, got %d", requested)
}
if requested > 0 {
return requested, nil
}
contextTokens, err := fetchRouterContextWindow(ctx, &http.Client{Timeout: routerModelsTimeout}, endpoint, model, apiKey)
if err != nil {
log.Warningf("resolve llm router context window for model %q failed: %v; using default %d", model, err, api.LLM_DEFAULT_CONTEXT_TOKENS)
contextTokens = api.LLM_DEFAULT_CONTEXT_TOKENS
}
return routerMaxPromptChars(contextTokens), nil
}
func (agent *SLLMRouterAgent) normalizeRouterUpdateMaxPromptChars(ctx context.Context, input *api.LLMRouterAgentUpdateInput) error {
if input.MaxPromptChars == nil {
return nil
}
requested := *input.MaxPromptChars
if requested != 0 {
maxPromptChars, err := resolveRouterMaxPromptChars(ctx, requested, "", "", "")
if err != nil {
return err
}
input.MaxPromptChars = &maxPromptChars
return nil
}
llmURL, model := agent.LLMUrl, agent.Model
if input.LLMUrl != nil {
llmURL = *input.LLMUrl
}
if input.Model != nil {
model = *input.Model
}
apiKey := ""
if input.ApiKey != nil {
apiKey = *input.ApiKey
} else {
storedAPIKey, err := agent.GetApiKey()
if err != nil {
return errors.Wrap(err, "decrypt llm router api key")
}
apiKey = storedAPIKey
}
maxPromptChars, err := resolveRouterMaxPromptChars(ctx, 0, llmURL, model, apiKey)
if err != nil {
return err
}
input.MaxPromptChars = &maxPromptChars
return nil
}
func normalizeCandidateMapping(in map[string][]string) map[string][]string {
out := make(map[string][]string, len(in))
for route, models := range in {
route = normalizeRouterRoute(route)
for _, model := range models {
model = strings.TrimSpace(model)
if model != "" {
out[route] = append(out[route], model)
}
}
}
return out
}
func normalizeRouterPromptConfig(cfg api.LLMRouterPromptConfig) api.LLMRouterPromptConfig {
cfg.SimpleDefinition = normalizeRouterDefinition(cfg.SimpleDefinition, api.LLM_ROUTER_DEFAULT_SIMPLE_DEFINITION)
cfg.ComplexDefinition = normalizeRouterDefinition(cfg.ComplexDefinition, api.LLM_ROUTER_DEFAULT_COMPLEX_DEFINITION)
cfg.SimpleExamples = normalizeRouterExamples(cfg.SimpleExamples)
cfg.ComplexExamples = normalizeRouterExamples(cfg.ComplexExamples)
return cfg
}
func normalizeRouterDefinition(definition, fallback string) string {
definition = strings.TrimSpace(definition)
if definition == "" {
return fallback
}
return definition
}
func normalizeRouterExamples(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]struct{}{}
for _, example := range in {
example = strings.TrimSpace(example)
if example == "" {
continue
}
if _, ok := seen[example]; ok {
continue
}
seen[example] = struct{}{}
out = append(out, example)
}
return out
}
func normalizeRouterPromptUpdate(input *api.LLMRouterAgentUpdateInput) {
if input.SimpleDefinition != nil {
definition := normalizeRouterDefinition(*input.SimpleDefinition, api.LLM_ROUTER_DEFAULT_SIMPLE_DEFINITION)
input.SimpleDefinition = &definition
}
if input.ComplexDefinition != nil {
definition := normalizeRouterDefinition(*input.ComplexDefinition, api.LLM_ROUTER_DEFAULT_COMPLEX_DEFINITION)
input.ComplexDefinition = &definition
}
if input.SimpleExamples != nil {
examples := normalizeRouterExamples(*input.SimpleExamples)
input.SimpleExamples = &examples
}
if input.ComplexExamples != nil {
examples := normalizeRouterExamples(*input.ComplexExamples)
input.ComplexExamples = &examples
}
}
func (man *SLLMRouterAgentManager) ListItemFilter(ctx context.Context, q *sqlchemy.SQuery, userCred mcclient.TokenCredential, input api.LLMRouterAgentListInput) (*sqlchemy.SQuery, error) {
q, err := man.SSharableVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, input.SharableVirtualResourceListInput)
if err != nil {
return nil, errors.Wrap(err, "SSharableVirtualResourceBaseManager.ListItemFilter")
}
if len(input.LLMDriver) > 0 {
q = q.Equals("llm_driver", normalizeRouterLLMDriver(input.LLMDriver))
}
return q, nil
}
func (manager *SLLMRouterAgentManager) FetchCustomizeColumns(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, objs []interface{}, fields stringutils2.SSortedStrings, isList bool) []api.LLMRouterAgentDetails {
rows := make([]api.LLMRouterAgentDetails, len(objs))
vrows := manager.SSharableVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
agents := []SLLMRouterAgent{}
jsonutils.Update(&agents, objs)
llmIds := make([]string, 0)
for i := range agents {
if agents[i].LLMId != "" {
llmIds = append(llmIds, agents[i].LLMId)
}
}
var llmIdNameMap map[string]string
if len(llmIds) > 0 {
var err error
llmIdNameMap, err = db.FetchIdNameMap2(GetLLMManager(), llmIds)
if err != nil {
log.Errorf("FetchIdNameMap2 for LLMs failed: %v", err)
}
}
for i := range rows {
rows[i].SharableVirtualResourceDetails = vrows[i]
if i < len(agents) {
rows[i].LLMId = agents[i].LLMId
if name, ok := llmIdNameMap[agents[i].LLMId]; ok {
rows[i].LLMName = name
}
}
}
return rows
}
func (agent *SLLMRouterAgent) PerformRoute(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.LLMRouterRouteRequest) (jsonutils.JSONObject, error) {
out, err := agent.Route(ctx, input)
if err != nil {
return nil, err
}
return jsonutils.Marshal(out), nil
}
func (agent *SLLMRouterAgent) PerformExpandExamples(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.LLMRouterExpandExamplesInput) (jsonutils.JSONObject, error) {
cfg := agent.promptConfig()
applyRouterPromptDraft(&cfg, input)
cfg = normalizeRouterPromptConfig(cfg)
content, err := callRouterAgentModel(ctx, agent, routerExpandSystemPrompt, buildRouterExpandPrompt(cfg))
if err != nil {
return nil, errors.Wrap(err, "expand router examples")
}
out, err := parseRouterPromptConfig(content)
if err != nil {
return nil, errors.Wrap(err, "parse expanded router examples")
}
return jsonutils.Marshal(out), nil
}
func (agent *SLLMRouterAgent) Route(ctx context.Context, req api.LLMRouterRouteRequest) (*api.LLMRouterRouteResponse, error) {
if len(req.Candidates) == 0 {
return nil, errors.Wrap(httperrors.ErrInputParameter, "candidates is required")
}
route, ok := agent.routeByRules(req)
if !ok {
route = agent.routeByDecisionModel(ctx, req)
}
if route == "" {
route = normalizeRouterRoute(agent.DefaultRoute)
}
model := agent.pickCandidate(route, req.Candidates)
if model == "" {
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "no selectable candidate model")
}
return &api.LLMRouterRouteResponse{Model: model}, nil
}
func (agent *SLLMRouterAgent) routeByRules(req api.LLMRouterRouteRequest) (string, bool) {
prompt := agent.requestText(req)
maxChars := normalizePositiveInt(agent.MaxPromptChars, api.LLM_ROUTER_DEFAULT_MAX_PROMPT_CHARS)
if len([]rune(prompt)) > maxChars {
return api.LLM_ROUTER_ROUTE_COMPLEX, true
}
return "", false
}
const (
routerDecisionSystemPrompt = "你是模型分流器。根据给定定义和样例把请求分类为 simple 或 complex。只能输出一个 JSON 对象,不得输出 Markdown、代码块或额外解释。"
routerExpandSystemPrompt = "你是模型分流规则编辑器。规范 simple 和 complex 的定义并补充代表性样例。只能输出指定的 JSON 对象,不得输出 Markdown、代码块或额外解释。"
)
var callRouterAgentModel = func(ctx context.Context, agent *SLLMRouterAgent, systemPrompt, userPrompt string) (string, error) {
if strings.TrimSpace(agent.LLMUrl) == "" || strings.TrimSpace(agent.Model) == "" {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "router model URL and model are required")
}
driver, err := GetLLMClientDriverWithError(api.LLMClientType(normalizeRouterLLMDriver(agent.LLMDriver)))
if err != nil {
return "", err
}
apiKey, err := agent.GetApiKey()
if err != nil {
return "", err
}
tmp := &SMCPAgent{
LLMUrl: agent.LLMUrl,
LLMDriver: normalizeRouterLLMDriver(agent.LLMDriver),
Model: agent.Model,
ApiKey: apiKey,
}
timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resp, err := driver.Chat(callCtx, tmp, []ILLMChatMessage{
driver.NewSystemMessage(systemPrompt),
driver.NewUserMessage(userPrompt),
}, nil)
if err != nil {
return "", err
}
return resp.GetContent(), nil
}
func (agent *SLLMRouterAgent) routeByDecisionModel(ctx context.Context, req api.LLMRouterRouteRequest) string {
prompt := agent.BuildDecisionPrompt(req)
content, err := callRouterAgentModel(ctx, agent, routerDecisionSystemPrompt, prompt)
if err != nil {
log.Warningf("llm router decision model failed: %v", err)
return normalizeRouterRoute(agent.DefaultRoute)
}
decision, err := parseRouterDecision(content)
if err != nil || decision.Confidence < 0.5 {
if err != nil {
log.Warningf("parse llm router decision failed: %v", err)
}
return normalizeRouterRoute(agent.DefaultRoute)
}
return normalizeRouterRoute(decision.Route)
}
func (agent *SLLMRouterAgent) BuildDecisionPrompt(req api.LLMRouterRouteRequest) string {
promptConfig := agent.promptConfig()
maxTokens := normalizePositiveInt(agent.MaxDecisionTokens, api.LLM_ROUTER_DEFAULT_MAX_DECISION_TOKENS)
return fmt.Sprintf(`根据以下定义和样例选择 route。
simple 定义:
%s
complex 定义:
%s
simple 样例:
%s
complex 样例:
%s
候选模型:%s
最多输出约 %d tokens。
只返回 JSON,不要解释:
{"route":"simple or complex","confidence":0.0,"reason":"short reason"}
请求内容:
%s`, promptConfig.SimpleDefinition, promptConfig.ComplexDefinition, formatRouterExamples(promptConfig.SimpleExamples), formatRouterExamples(promptConfig.ComplexExamples), strings.Join(req.Candidates, ", "), maxTokens, agent.requestText(req))
}
func buildRouterExpandPrompt(cfg api.LLMRouterPromptConfig) string {
return fmt.Sprintf(`请规范以下二分类定义和样例,并补充少量有代表性的样例。保持 simple 和 complex 边界互斥且清晰,不要增加其他 route。
simple 定义:
%s
complex 定义:
%s
simple 样例:
%s
complex 样例:
%s
只返回:
{"simple_definition":"...","complex_definition":"...","simple_examples":["..."],"complex_examples":["..."]}`,
cfg.SimpleDefinition,
cfg.ComplexDefinition,
formatRouterExamples(cfg.SimpleExamples),
formatRouterExamples(cfg.ComplexExamples),
)
}
func applyRouterPromptDraft(cfg *api.LLMRouterPromptConfig, input api.LLMRouterExpandExamplesInput) {
if input.SimpleDefinition != nil {
cfg.SimpleDefinition = *input.SimpleDefinition
}
if input.ComplexDefinition != nil {
cfg.ComplexDefinition = *input.ComplexDefinition
}
if input.SimpleExamples != nil {
cfg.SimpleExamples = *input.SimpleExamples
}
if input.ComplexExamples != nil {
cfg.ComplexExamples = *input.ComplexExamples
}
}
func (agent *SLLMRouterAgent) promptConfig() api.LLMRouterPromptConfig {
cfg := api.LLMRouterPromptConfig{
SimpleDefinition: agent.SimpleDefinition,
ComplexDefinition: agent.ComplexDefinition,
}
if agent.SimpleExamples != nil {
if err := agent.SimpleExamples.Unmarshal(&cfg.SimpleExamples); err != nil {
log.Warningf("invalid simple_examples for llm router agent %s: %v", agent.Id, err)
}
}
if agent.ComplexExamples != nil {
if err := agent.ComplexExamples.Unmarshal(&cfg.ComplexExamples); err != nil {
log.Warningf("invalid complex_examples for llm router agent %s: %v", agent.Id, err)
}
}
return normalizeRouterPromptConfig(cfg)
}
func formatRouterExamples(examples []string) string {
if len(examples) == 0 {
return "无"
}
return "- " + strings.Join(examples, "\n- ")
}
func (agent *SLLMRouterAgent) requestText(req api.LLMRouterRouteRequest) string {
parts := make([]string, 0, len(req.Messages))
for _, msg := range req.Messages {
switch val := msg.Content.(type) {
case string:
parts = append(parts, val)
default:
parts = append(parts, jsonutils.Marshal(val).String())
}
}
return strings.Join(parts, "\n")
}
func (agent *SLLMRouterAgent) candidateMapping() map[string][]string {
ret := map[string][]string{}
if agent.CandidateMapping == nil {
return ret
}
if err := agent.CandidateMapping.Unmarshal(&ret); err != nil {
log.Warningf("invalid llm router candidate_mapping: %v", err)
return map[string][]string{}
}
return normalizeCandidateMapping(ret)
}
func (agent *SLLMRouterAgent) pickCandidate(route string, candidates []string) string {
mapping := agent.candidateMapping()
if model := pickCandidateFromList(mapping[normalizeRouterRoute(route)], candidates); model != "" {
return model
}
def := normalizeRouterRoute(agent.DefaultRoute)
if def != normalizeRouterRoute(route) {
if model := pickCandidateFromList(mapping[def], candidates); model != "" {
return model
}
}
if model := pickCandidateFromList([]string{route}, candidates); model != "" {
return model
}
return strings.TrimSpace(candidates[0])
}
func pickCandidateFromList(models []string, candidates []string) string {
for _, model := range models {
model = strings.TrimSpace(model)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate), model) {
return strings.TrimSpace(candidate)
}
}
}
return ""
}
func parseRouterDecision(content string) (*api.LLMRouterDecision, error) {
content = extractRouterJSON(content)
decision := api.LLMRouterDecision{}
if err := json.Unmarshal([]byte(content), &decision); err != nil {
return nil, err
}
decision.Route = normalizeRouterRoute(decision.Route)
if decision.Route == "" {
return nil, errors.Error("missing route")
}
if decision.Confidence == 0 {
decision.Confidence = 1
}
return &decision, nil
}
func parseRouterPromptConfig(content string) (*api.LLMRouterPromptConfig, error) {
var raw struct {
SimpleDefinition *string `json:"simple_definition"`
ComplexDefinition *string `json:"complex_definition"`
SimpleExamples *[]string `json:"simple_examples"`
ComplexExamples *[]string `json:"complex_examples"`
}
if err := json.Unmarshal([]byte(extractRouterJSON(content)), &raw); err != nil {
return nil, err
}
if raw.SimpleDefinition == nil || raw.ComplexDefinition == nil || raw.SimpleExamples == nil || raw.ComplexExamples == nil {
return nil, errors.Error("router expansion response missing required fields")
}
cfg := normalizeRouterPromptConfig(api.LLMRouterPromptConfig{
SimpleDefinition: *raw.SimpleDefinition,
ComplexDefinition: *raw.ComplexDefinition,
SimpleExamples: *raw.SimpleExamples,
ComplexExamples: *raw.ComplexExamples,
})
return &cfg, nil
}
func extractRouterJSON(content string) string {
content = strings.TrimSpace(content)
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
if start := strings.Index(content, "{"); start >= 0 {
if end := strings.LastIndex(content, "}"); end > start {
content = content[start : end+1]
}
}
return content
}
+38
View File
@@ -304,6 +304,41 @@ func handleDefaultMcpTools(ctx context.Context, w http.ResponseWriter, r *http.R
appsrv.SendJSON(w, result)
}
func handleLLMRouterAgentRoute(ctx context.Context, w http.ResponseWriter, r *http.Request) {
params, _, body := appsrv.FetchEnv(ctx, w, r)
id := params["<id>"]
if id == "" {
httperrors.MissingParameterError(ctx, w, "id")
return
}
if body == nil {
body = jsonutils.NewDict()
}
if body.Contains(models.GetLLMRouterAgentManager().Keyword()) {
agentObj, _ := body.Get(models.GetLLMRouterAgentManager().Keyword())
if agentObj != nil {
body = agentObj
}
}
input := api.LLMRouterRouteRequest{}
if err := body.Unmarshal(&input); err != nil {
httperrors.InvalidInputError(ctx, w, "invalid input: %v", err)
return
}
obj, err := models.GetLLMRouterAgentManager().FetchByIdOrName(ctx, nil, id)
if err != nil {
httperrors.GeneralServerError(ctx, w, err)
return
}
agent := obj.(*models.SLLMRouterAgent)
out, err := agent.Route(ctx, input)
if err != nil {
httperrors.GeneralServerError(ctx, w, err)
return
}
appsrv.SendStruct(w, out)
}
func InitHandlers(app *appsrv.Application, isSlave bool) {
db.InitAllManagers()
db.RegistUserCredCacheUpdater()
@@ -340,6 +375,8 @@ func InitHandlers(app *appsrv.Application, isSlave bool) {
// 默认 MCP 服务器 tools:仅使用 options.MCPServerURL,不依赖 mcp_agent 条目
app.AddHandler2("GET", "/mcp_agents/default-mcp-tools", auth.Authenticate(handleDefaultMcpTools), nil, "default_mcp_tools", nil)
app.AddHandler2("POST", "/llm_router_agents/<id>/route", handleLLMRouterAgentRoute, nil, "llm_router_agent_route", nil)
for _, manager := range []db.IModelManager{
taskman.TaskManager,
taskman.SubTaskManager,
@@ -372,6 +409,7 @@ func InitHandlers(app *appsrv.Application, isSlave bool) {
models.GetInstantModelManager(),
models.GetLLMInstantModelManager(),
models.GetMCPAgentManager(),
models.GetLLMRouterAgentManager(),
} {
db.RegisterModelManager(manager)
handler := db.NewModelHandler(manager)
@@ -0,0 +1,24 @@
package llm
import (
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
"yunion.io/x/onecloud/pkg/mcclient/modules"
)
type LLMRouterAgentManager struct {
modulebase.ResourceManager
}
var (
LLMRouterAgent LLMRouterAgentManager
)
func init() {
LLMRouterAgent = LLMRouterAgentManager{
ResourceManager: modules.NewLLMManager("llm_router_agent", "llm_router_agents",
[]string{},
[]string{},
),
}
modules.Register(&LLMRouterAgent)
}
+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|llm-router|desktop|benchmark" help:"filter by llm type"`
LLMType string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|desktop|benchmark" 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|llm-router|desktop|benchmark" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, llm-router, desktop, benchmark or dify"`
LLM_TYPE string `json:"llm_type" choices:"ollama|dify|comfyui|vllm|sglang|hermes-agent|desktop|benchmark" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, desktop, benchmark 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|llm-router|desktop|benchmark" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, llm-router, desktop, benchmark or dify"`
LlmType string `json:"llm_type" choices:"ollama|dify|vllm|sglang|comfyui|hermes-agent|desktop|benchmark" help:"llm type: ollama, comfyui, vllm, sglang, hermes-agent, desktop, benchmark 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|llm-router" json:"-"`
SkuLLMType string `help:"container llm type" choices:"ollama|vllm|comfyui|sglang|dify" json:"-"`
SkuCpu int `help:"SKU CPU cores" json:"-"`
SkuMemory int `help:"SKU memory MB" json:"-"`
SkuDiskSize int `help:"SKU disk size MB" json:"-"`
@@ -0,0 +1,203 @@
package llm
import (
"fmt"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/pkg/apis"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/mcclient/options"
)
type LLMRouterAgentListOptions struct {
options.BaseListOptions
LLMDriver string `json:"llm_driver" help:"filter by llm driver"`
}
func (o *LLMRouterAgentListOptions) Params() (jsonutils.JSONObject, error) {
return options.ListStructToParams(o)
}
type LLMRouterAgentShowOptions struct {
options.BaseShowOptions
}
func (o *LLMRouterAgentShowOptions) Params() (jsonutils.JSONObject, error) {
return options.StructToParams(o)
}
type LLMRouterAgentCreateOptions struct {
apis.SharableVirtualResourceCreateInput
LlmId string `help:"LLM instance ID; if set, llm_url is resolved from it" json:"llm_id"`
LLM_URL string `help:"decision model OpenAI-compatible base URL" json:"llm_url"`
LLM_DRIVER string `help:"decision model driver" json:"llm_driver" choices:"ollama|openai"`
MODEL string `help:"decision model name" json:"model"`
API_KEY string `help:"decision model API key" json:"api_key"`
DefaultRoute string `help:"fallback route, default complex" json:"default_route"`
MaxPromptChars int `help:"request char limit; 0 auto-detects /v1/models context, longer requests use complex" json:"max_prompt_chars"`
MaxDecisionTokens int `help:"decision answer token hint" json:"max_decision_tokens"`
CandidateMapping string `help:"candidate mapping JSON, e.g. '{\"simple\":[\"qwen\"],\"complex\":[\"deepseek\"]}'" json:"-"`
SimpleDefinition string `help:"definition of simple requests" json:"simple_definition"`
ComplexDefinition string `help:"definition of complex requests" json:"complex_definition"`
SimpleExamples string `help:"simple examples JSON array" json:"-"`
ComplexExamples string `help:"complex examples JSON array" json:"-"`
}
func (o *LLMRouterAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
obj := jsonutils.Marshal(o).(*jsonutils.JSONDict)
if o.CandidateMapping != "" {
mapping, err := parseRouterCandidateMapping(o.CandidateMapping)
if err != nil {
return nil, err
}
obj.Set("candidate_mapping", jsonutils.Marshal(mapping))
}
if err := setRouterExamples(obj, "simple_examples", o.SimpleExamples); err != nil {
return nil, err
}
if err := setRouterExamples(obj, "complex_examples", o.ComplexExamples); err != nil {
return nil, err
}
return obj, nil
}
type LLMRouterAgentUpdateOptions struct {
apis.SharableVirtualResourceBaseUpdateInput
ID string
LlmId *string `help:"LLM instance ID; if set, llm_url is resolved from it" json:"llm_id,omitempty"`
LlmUrl *string `help:"decision model OpenAI-compatible base URL" json:"llm_url,omitempty"`
LlmDriver *string `help:"decision model driver" json:"llm_driver,omitempty" choices:"ollama|openai"`
Model *string `help:"decision model name" json:"model,omitempty"`
ApiKey *string `help:"decision model API key" json:"api_key,omitempty"`
DefaultRoute *string `help:"fallback route" json:"default_route,omitempty"`
MaxPromptChars *int `help:"request char limit; 0 recomputes from /v1/models context" json:"max_prompt_chars,omitempty"`
MaxDecisionTokens *int `help:"decision answer token hint" json:"max_decision_tokens,omitempty"`
CandidateMapping string `help:"candidate mapping JSON" json:"-"`
SimpleDefinition *string `help:"definition of simple requests" json:"simple_definition,omitempty"`
ComplexDefinition *string `help:"definition of complex requests" json:"complex_definition,omitempty"`
SimpleExamples string `help:"replace simple examples with JSON array" json:"-"`
ComplexExamples string `help:"replace complex examples with JSON array" json:"-"`
}
func (o *LLMRouterAgentUpdateOptions) GetId() string {
return o.ID
}
func (o *LLMRouterAgentUpdateOptions) Params() (jsonutils.JSONObject, error) {
obj := jsonutils.Marshal(o).(*jsonutils.JSONDict)
obj.Remove("id")
if o.CandidateMapping != "" {
mapping, err := parseRouterCandidateMapping(o.CandidateMapping)
if err != nil {
return nil, err
}
obj.Set("candidate_mapping", jsonutils.Marshal(mapping))
}
if err := setRouterExamples(obj, "simple_examples", o.SimpleExamples); err != nil {
return nil, err
}
if err := setRouterExamples(obj, "complex_examples", o.ComplexExamples); err != nil {
return nil, err
}
return obj, nil
}
type LLMRouterAgentDeleteOptions struct {
options.BaseIdOptions
}
func (o *LLMRouterAgentDeleteOptions) GetId() string {
return o.ID
}
func (o *LLMRouterAgentDeleteOptions) Params() (jsonutils.JSONObject, error) {
return options.StructToParams(o)
}
type LLMRouterAgentRouteOptions struct {
ID string `help:"llm router agent id or name" json:"-"`
Model string `help:"requested model" json:"model"`
Message []string `help:"user message; repeatable" json:"-"`
Candidates []string `help:"candidate model; repeatable" json:"candidates"`
}
func (o *LLMRouterAgentRouteOptions) GetId() string {
return o.ID
}
func (o *LLMRouterAgentRouteOptions) Params() (jsonutils.JSONObject, error) {
messages := make([]api.LLMRouterMessage, 0, len(o.Message))
for _, msg := range o.Message {
messages = append(messages, api.LLMRouterMessage{Role: "user", Content: msg})
}
input := api.LLMRouterRouteRequest{
Model: o.Model,
Messages: messages,
Candidates: o.Candidates,
}
return jsonutils.Marshal(input), nil
}
type LLMRouterAgentExpandExamplesOptions struct {
ID string `help:"llm router agent id or name" json:"-"`
SimpleDefinition *string `help:"draft definition of simple requests" json:"simple_definition,omitempty"`
ComplexDefinition *string `help:"draft definition of complex requests" json:"complex_definition,omitempty"`
SimpleExamples string `help:"draft simple examples JSON array" json:"-"`
ComplexExamples string `help:"draft complex examples JSON array" json:"-"`
}
func (o *LLMRouterAgentExpandExamplesOptions) GetId() string {
return o.ID
}
func (o *LLMRouterAgentExpandExamplesOptions) Params() (jsonutils.JSONObject, error) {
obj := jsonutils.Marshal(o).(*jsonutils.JSONDict)
obj.Remove("id")
if err := setRouterExamples(obj, "simple_examples", o.SimpleExamples); err != nil {
return nil, err
}
if err := setRouterExamples(obj, "complex_examples", o.ComplexExamples); err != nil {
return nil, err
}
return obj, nil
}
func parseRouterCandidateMapping(raw string) (map[string][]string, error) {
obj, err := jsonutils.ParseString(raw)
if err != nil {
return nil, fmt.Errorf("failed to parse candidate mapping JSON: %v", err)
}
mapping := map[string][]string{}
if err := obj.Unmarshal(&mapping); err != nil {
return nil, fmt.Errorf("failed to unmarshal candidate mapping: %v", err)
}
return mapping, nil
}
func setRouterExamples(obj *jsonutils.JSONDict, key, raw string) error {
if raw == "" {
return nil
}
examples, err := parseRouterExamples(raw)
if err != nil {
return err
}
obj.Set(key, jsonutils.Marshal(examples))
return nil
}
func parseRouterExamples(raw string) ([]string, error) {
obj, err := jsonutils.ParseString(raw)
if err != nil {
return nil, fmt.Errorf("failed to parse router examples JSON: %v", err)
}
examples := []string{}
if err := obj.Unmarshal(&examples); err != nil {
return nil, fmt.Errorf("failed to unmarshal router examples: %v", err)
}
return examples, nil
}
+2 -49
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|llm-router|desktop"`
LLMType string `json:"llm_type" choices:"ollama|vllm|sglang|dify|comfyui|openclaw|hermes-agent|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|llm-router|desktop"`
LLM_TYPE string `json:"llm_type" choices:"ollama|vllm|sglang|comfyui|hermes-agent|desktop"`
// Model source
Source string `help:"model source: huggingface, model_scope, local_path" json:"source"`
@@ -67,14 +67,6 @@ 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) {
@@ -134,49 +126,10 @@ 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")