feat: implement hgctl agent module (#3267)

This commit is contained in:
xingpiaoliang
2025-12-26 13:47:32 +08:00
committed by GitHub
parent e7e3ab5ff6
commit 17e80b30fe
31 changed files with 5858 additions and 652 deletions

View File

@@ -31,7 +31,33 @@ type HigressClient struct {
httpClient *http.Client
}
type HimarketClient struct {
baseURL string
username string
password string
httpClient *http.Client
jwtToken string
}
// type ClientType string
// const (
// HigressClientType ClientType = "higress"
// HimarketClientType ClientType = "himarket"
// )
// func NewClient(clientType ClientType, baseURL, username, password string) Client {
// switch clientType {
// case HimarketClientType:
// return NewHimarketClient(baseURL, username, password)
// case HigressClientType:
// fallthrough
// default:
// return NewHigressClient(baseURL, username, password)
// }
// }
func NewHigressClient(baseURL, username, password string) *HigressClient {
client := &HigressClient{
baseURL: baseURL,
username: username,
@@ -44,6 +70,19 @@ func NewHigressClient(baseURL, username, password string) *HigressClient {
return client
}
func NewHimarketClient(baseURL, username, password string) *HimarketClient {
client := &HimarketClient{
baseURL: baseURL,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
return client
}
func (c *HigressClient) Get(path string) ([]byte, error) {
return c.request("GET", path, nil)
}
@@ -59,6 +98,133 @@ func (c *HigressClient) Put(path string, data interface{}) ([]byte, error) {
func (c *HigressClient) Delete(path string) ([]byte, error) {
return c.request("DELETE", path, nil)
}
func (c *HimarketClient) getJWTToken() error {
loginURL := c.baseURL + "/api/v1/admins/login"
loginData := map[string]string{
"username": c.username,
"password": c.password,
}
jsonData, err := json.Marshal(loginData)
if err != nil {
return fmt.Errorf("failed to marshal login data: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create login request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("login request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("login failed with status code: %d", resp.StatusCode)
}
var response map[string]interface{}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read login response: %w", err)
}
if err := json.Unmarshal(respBody, &response); err != nil {
return fmt.Errorf("failed to parse login response: %w", err)
}
// fmt.Println(string(respBody))
if data, ok := response["data"].(map[string]interface{}); ok {
if token, ok := data["access_token"].(string); ok {
c.jwtToken = token
return nil
}
}
return fmt.Errorf("token not found in login response: %v", response)
}
func (c *HimarketClient) Get(path string) ([]byte, error) {
return c.request("GET", path, nil)
}
func (c *HimarketClient) Post(path string, data interface{}) ([]byte, error) {
return c.request("POST", path, data)
}
func (c *HimarketClient) Put(path string, data interface{}) ([]byte, error) {
return c.request("PUT", path, data)
}
func (c *HimarketClient) request(method, path string, data interface{}) ([]byte, error) {
if c.jwtToken == "" {
if err := c.getJWTToken(); err != nil {
return nil, fmt.Errorf("failed to get JWT token: %w", err)
}
}
url := c.baseURL + path
var body io.Reader
if data != nil {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("failed to marshal request data: %w", err)
}
body = bytes.NewBuffer(jsonData)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.jwtToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 409 {
return nil, fmt.Errorf("resource already exists")
}
if resp.StatusCode == 400 {
return nil, fmt.Errorf("invalid resource definition")
}
if resp.StatusCode == 500 {
return nil, fmt.Errorf("server internal error")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP error %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return respBody, nil
}
func (c *HigressClient) request(method, path string, data interface{}) ([]byte, error) {
url := c.baseURL + path
@@ -92,6 +258,8 @@ func (c *HigressClient) request(method, path string, data interface{}) ([]byte,
return nil, fmt.Errorf("resource already exists")
}
// fmt.Println(resp)
if resp.StatusCode == 400 {
return nil, fmt.Errorf("invalid resource definition")
}

View File

@@ -15,7 +15,11 @@
package services
import (
"encoding/json"
"fmt"
"time"
"github.com/alibaba/higress/hgctl/pkg/agent/common"
)
func HandleAddServiceSource(client *HigressClient, body interface{}) ([]byte, error) {
@@ -50,22 +54,30 @@ func HandleAddServiceSource(client *HigressClient, body interface{}) ([]byte, er
// add MCP server to higress console, example request body as followed:
//
// {
// "name": "mcp-deepwiki",
// "description": "",
// "type": "DIRECT_ROUTE", // or OPEN_API
// "service": "hgctl-deepwiki.dns:443",
// "upstreamPathPrefix": "/mcp",
// "name": "test",
// "description": "123",
// "type": "DIRECT_ROUTE",
// "services": [
// {
// "name": "hgctl-deepwiki.dns",
// "name": "hgctl-mcp-deepwiki.dns",
// "port": 443,
// "version": "1.0",
// "weight": 100
// }
// ]
// ],
// "consumerAuthInfo": {
// "type": "key-auth",
// "allowedConsumers": []
// },
// "domains": [],
// "directRouteConfig": {
// "path": "/mcp",
// "transportType": "streamable"
// }
// }
func HandleAddMCPServer(client *HigressClient, body interface{}) ([]byte, error) {
data, ok := body.(map[string]interface{})
// fmt.Printf("mcpbody: %v\n", data)
if !ok {
return nil, fmt.Errorf("failed to parse request body")
}
@@ -76,10 +88,6 @@ func HandleAddMCPServer(client *HigressClient, body interface{}) ([]byte, error)
if _, ok := data["type"]; !ok {
return nil, fmt.Errorf("missing required field 'type' in body")
}
if _, ok := data["service"]; !ok {
return nil, fmt.Errorf("missing required field 'service' in body")
}
// if _, ok := data["upstreamPathPrefix"]; !ok {
// return nil, fmt.Errorf("missing required field 'upstreamPathPrefix' in body")
// }
@@ -97,6 +105,40 @@ func HandleAddMCPServer(client *HigressClient, body interface{}) ([]byte, error)
return resp, nil
}
// return map[mcp-server-name]{}
func GetExistingMCPServers(client *HigressClient) (map[string]string, error) {
result := make(map[string]string)
data, err := HandleListMCPServers(client)
if err != nil {
return nil, err
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to get product id from response: %s", err)
}
// fmt.Println(response["data"])
if list, ok := response["data"].([]interface{}); ok {
for _, item := range list {
if mcp, ok := item.(map[string]interface{}); ok {
if name, ok := mcp["name"].(string); ok {
result[name] = ""
}
}
}
}
return result, nil
}
func HandleListMCPServers(client *HigressClient) ([]byte, error) {
ts := time.Now().Unix()
pageNum := 1
pageSize := 100
return client.Get(fmt.Sprintf("/v1/mcpServer?ts=%d&pageNum=%d&pageSize=%d", ts, pageNum, pageSize))
}
// add OpenAPI MCP tools to higress console, example request body:
//
// {
@@ -127,3 +169,155 @@ func HandleAddMCPServer(client *HigressClient, body interface{}) ([]byte, error)
func HandleAddOpenAPITool(client *HigressClient, body interface{}) ([]byte, error) {
return client.Put("/v1/mcpServer", body)
}
func HandleAddAIProviderService(client *HigressClient, body interface{}) ([]byte, error) {
return client.Post("/v1/ai/providers", body)
}
func HandleAddAIRoute(client *HigressClient, body interface{}) ([]byte, error) {
return client.Post("/v1/ai/routes", body)
}
func HandleAddRoute(client *HigressClient, body interface{}) ([]byte, error) {
return client.Post("/v1/routes", body)
}
// Himarket-related
func HandleAddHigressInstance(client *HimarketClient, body interface{}) ([]byte, error) {
// This api will not return the higress-gatway-id
return client.Post("/api/v1/gateways", body)
}
func (c *HimarketClient) getProduct(typ common.ProductType) ([]byte, error) {
return c.Get(fmt.Sprintf("/api/v1/products?type=%s&page=0&size=30", string(typ)))
}
func (c *HimarketClient) extractGetProductResponse(typ common.ProductType, response map[string]interface{}) map[string]string {
result := make(map[string]string)
data, ok := response["data"].(map[string]interface{})
if !ok {
return result
}
content, ok := data["content"].([]interface{})
if !ok {
return result
}
for _, item := range content {
product, ok := item.(map[string]interface{})
if !ok {
continue
}
productType, _ := product["type"].(string)
if productType != string(typ) {
continue
}
name, _ := product["name"].(string)
if name == "" {
continue
}
mcpConfig, ok := product["mcpConfig"].(map[string]interface{})
if !ok {
continue
}
serverConfig, ok := mcpConfig["mcpServerConfig"].(map[string]interface{})
if !ok {
continue
}
domains, ok := serverConfig["domains"].([]interface{})
if !ok || len(domains) == 0 {
continue
}
path, ok := serverConfig["path"].(string)
if !ok {
continue
}
for _, domainItem := range domains {
domainConfig, ok := domainItem.(map[string]interface{})
if !ok {
continue
}
domain, _ := domainConfig["domain"].(string)
protocol, _ := domainConfig["protocol"].(string)
if domain == "" || protocol == "" {
continue
}
port, _ := domainConfig["port"].(float64)
url := ""
if port == 0 || port == 80 {
url = fmt.Sprintf("%s://%s%s", protocol, domain, path)
} else {
url = fmt.Sprintf("%s://%s:%d%s", protocol, domain, int(port), path)
}
result[name] = url
break
}
}
return result
}
func (c *HimarketClient) GetDevModelProduct() (map[string]string, error) {
data, err := c.getProduct(common.MODEL_API)
if err != nil {
return nil, fmt.Errorf("failed request himarket: %s", err)
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to get model api from response %s", err)
}
return c.extractGetProductResponse(common.MODEL_API, response), nil
}
func (c *HimarketClient) GetDevMCPServerProduct() (map[string]string, error) {
data, err := c.getProduct(common.MCP_SERVER)
if err != nil {
return nil, fmt.Errorf("failed request himarket: %s", err)
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to get MCP server from response %s", err)
}
return c.extractGetProductResponse(common.MCP_SERVER, response), nil
}
func HandleListHimarketMCPServers(client *HimarketClient) ([]byte, error) {
return nil, nil
}
func HandleAddAPIProduct(client *HimarketClient, body interface{}) ([]byte, error) {
data, err := client.Post("/api/v1/products", body)
if err != nil {
return data, err
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to get product id from response: %s", err)
}
if res, ok := response["data"].(map[string]interface{}); ok {
if productId, ok := res["productId"].(string); ok {
return []byte(productId), nil
}
}
return data, fmt.Errorf("failed to get product id from response")
}
func HandleRefAPIProduct(client *HimarketClient, product_id string, body interface{}) ([]byte, error) {
return client.Post(fmt.Sprintf("/api/v1/products/%s/ref", product_id), body)
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package services
import (
"fmt"
"net"
"net/url"
)
func BuildAIProviderServiceBody(name, url string) map[string]interface{} {
customBaseURL := fmt.Sprintf("%s/compatible-mode/v1", url)
return map[string]interface{}{
"type": "openai",
"name": name,
"tokens": []string{},
"version": 0,
"protocol": "openai/v1",
"tokenFailoverConfig": map[string]interface{}{
"enabled": false,
},
"proxyName": "",
"rawConfigs": map[string]interface{}{
"openaiExtraCustomUrls": []string{},
"openaiCustomUrl": customBaseURL,
},
}
}
func BuildAddAIRouteBody(name, _url string) map[string]interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("%s-route", name),
// "version": "627198", // It's unecessary to provide when create a new one
"domains": []interface{}{},
"pathPredicate": map[string]interface{}{
"matchType": "PRE",
// FIXME: Currently, to use model API in higress user hould follow this pattern:
// http://<higress-gateway-ip>/<PRE_MATCH_VALUE>/v1/chat/completions or /v1/embedding
// However in Himarket, when connecting the higress ai route as model API, himarket will directly use http://<higress-gateway-ip>/<PRE_MATCH_VALUE>
// as the final request url, which will not get to right path. So here we make the matchValue hard-coded as `/v1/chat/completions`
"matchValue": "/v1/chat/completions",
"caseSensitive": false,
"ignoreCase": []string{}, // "ignoreCase": ["ignore"]
},
"headerPredicates": []interface{}{},
"urlParamPredicates": []interface{}{},
"upstreams": []interface{}{
map[string]interface{}{
"provider": name,
"weight": 100,
"modelMapping": map[string]interface{}{},
},
},
"modelPredicates": []interface{}{},
"authConfig": map[string]interface{}{
"enabled": false,
"allowedCredentialTypes": []interface{}{},
"allowedConsumers": []interface{}{},
},
"fallbackConfig": map[string]interface{}{
"enabled": false,
"upstreams": nil,
"fallbackStrategy": nil,
"responseCodes": nil,
},
}
}
func BuildServiceBodyAndSrv(name, urlStr string) (map[string]interface{}, string, string, error) {
res, err := url.Parse(urlStr)
if err != nil {
return nil, "", "", err
}
// add service source
srvType := ""
srvPort := ""
if ip := net.ParseIP(res.Hostname()); ip == nil {
srvType = "dns"
} else {
srvType = "static"
}
if res.Port() == "" && res.Scheme == "http" {
srvPort = "80"
} else if res.Port() == "" && res.Scheme == "https" {
srvPort = "443"
} else {
srvPort = res.Port()
}
// e.g. hgctl-mcp-deepwiki.dns
targetSrvName := fmt.Sprintf("%s.%s", name, srvType)
return map[string]interface{}{
"domain": res.Host,
"type": srvType,
"port": srvPort,
"name": name,
"proxyName": "",
"domainForEdit": res.Host,
"protocol": res.Scheme,
}, targetSrvName, srvPort, nil
}
func BuildAPIRouteBody(name, srv string) map[string]interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("%s-route", name),
"path": map[string]interface{}{
"matchType": "PRE", // default is PREFIX
"matchValue": "/process", // default is "/process"
"caseSensitive": true,
},
"authConfig": map[string]interface{}{
"enabled": false,
},
"services": []map[string]interface{}{
{
"name": srv,
},
},
}
}
func BuildAddHigressInstanceBody(name, addr, username, password string) map[string]interface{} {
return map[string]interface{}{
"gatewayName": name,
"gatewayType": "HIGRESS",
"higressConfig": map[string]interface{}{
"address": addr,
"username": username,
"password": password,
},
}
}
func BuildAPIProductBody(name, desc, typ string) map[string]interface{} {
return map[string]interface{}{
"name": name, "description": desc, "type": typ,
}
}
func BuildRefModelAPIProductBody(gateway_id, product_id, target_route string) map[string]interface{} {
return map[string]interface{}{
"gatewayId": gateway_id,
"sourceType": "GATEWAY",
"productId": product_id,
"higressRefConfig": map[string]interface{}{
"modelRouteName": target_route,
"fromGatewayType": "HIGRESS",
},
}
}
func BuildRefMCPAPIProductBody(gateway_id, product_id, mcp_name string) map[string]interface{} {
return map[string]interface{}{
"gatewayId": gateway_id,
"sourceType": "GATEWAY",
"productId": product_id,
"higressRefConfig": map[string]interface{}{
"mcpServerName": mcp_name,
"fromGatewayType": "HIGRESS",
},
}
}