mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
Compare commits
115 Commits
release/4.
...
v4.0.4-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc0e3b5ec | ||
|
|
df217c1be2 | ||
|
|
adf05b2988 | ||
|
|
3c35e76604 | ||
|
|
790d1437a8 | ||
|
|
5d5c052a9b | ||
|
|
3047bbdcda | ||
|
|
2321b65ee1 | ||
|
|
70391d0158 | ||
|
|
af794715fd | ||
|
|
4f1706d285 | ||
|
|
d8facf9ffd | ||
|
|
8cab0232c4 | ||
|
|
c053206af9 | ||
|
|
a79c399c2f | ||
|
|
add8d8381f | ||
|
|
2b0d3d4362 | ||
|
|
413f014f9c | ||
|
|
32477e1620 | ||
|
|
4c3c641c97 | ||
|
|
919d2b9d56 | ||
|
|
4e798c8b95 | ||
|
|
071e4fcb2d | ||
|
|
4126bd8494 | ||
|
|
90364a81df | ||
|
|
3bcd053ad5 | ||
|
|
5ea244bc5c | ||
|
|
b3905f139e | ||
|
|
8b2994a380 | ||
|
|
637c5b15a2 | ||
|
|
69f9ff2a61 | ||
|
|
8abb4057f1 | ||
|
|
8f13c93e28 | ||
|
|
a61a824884 | ||
|
|
cc2cd72a2f | ||
|
|
fa594fe753 | ||
|
|
24a245fd4f | ||
|
|
dbb6c4a17b | ||
|
|
139df655b7 | ||
|
|
04c6ed26bf | ||
|
|
8bff91b52d | ||
|
|
2898cdb20a | ||
|
|
c8ddc8665f | ||
|
|
9f318bbff6 | ||
|
|
8081f677c7 | ||
|
|
a2d0a0a799 | ||
|
|
d109f5bf35 | ||
|
|
8aed63bd0d | ||
|
|
c241fc1210 | ||
|
|
388bba8e85 | ||
|
|
945a042676 | ||
|
|
0a06731b8d | ||
|
|
425231aa64 | ||
|
|
89c3068a38 | ||
|
|
f19c914dce | ||
|
|
73265ea559 | ||
|
|
636b81973c | ||
|
|
d7e4a43b9b | ||
|
|
ce2c4a588f | ||
|
|
f6edc92c62 | ||
|
|
4096e77d48 | ||
|
|
82b468de2e | ||
|
|
5b6170421e | ||
|
|
72eb994f68 | ||
|
|
b12fe78267 | ||
|
|
be6ae61ea4 | ||
|
|
56a5dbcaaf | ||
|
|
9db72a1b5c | ||
|
|
1baffa21e6 | ||
|
|
dc12c191fd | ||
|
|
b19a23c701 | ||
|
|
e8f046de4f | ||
|
|
9d0b4bb975 | ||
|
|
50d78a7985 | ||
|
|
3d173686fc | ||
|
|
5ea8def943 | ||
|
|
bd33225ed0 | ||
|
|
874f82dfac | ||
|
|
9e89074f86 | ||
|
|
bd3a1d0de9 | ||
|
|
e6621aacf0 | ||
|
|
97fc254cf5 | ||
|
|
3d3ae1ef0c | ||
|
|
77aa8c9471 | ||
|
|
8d490d5a3a | ||
|
|
2911f92f9b | ||
|
|
c1980ee7fe | ||
|
|
203f5bb396 | ||
|
|
4a729a7639 | ||
|
|
712ce702ce | ||
|
|
80ea7e60e1 | ||
|
|
daf21aa6a3 | ||
|
|
6bc05ca5c1 | ||
|
|
ea0e1ca42f | ||
|
|
94862f5232 | ||
|
|
887bb943b8 | ||
|
|
205dbe2f64 | ||
|
|
554cdd01be | ||
|
|
da03a421a5 | ||
|
|
06517e5aa6 | ||
|
|
6d49bee5e6 | ||
|
|
033408e7d0 | ||
|
|
fe1846f690 | ||
|
|
7aa20811f5 | ||
|
|
93971e83fb | ||
|
|
1950824dfa | ||
|
|
7fec30216b | ||
|
|
23e1910817 | ||
|
|
a8f3474fb2 | ||
|
|
bee68803b2 | ||
|
|
f58fcc2999 | ||
|
|
fcbf833fde | ||
|
|
af2e410c24 | ||
|
|
f6e32e7707 | ||
|
|
277546c95c |
2
Makefile
2
Makefile
@@ -261,7 +261,7 @@ dep:
|
||||
@echo "$$depDeprecated"
|
||||
@$(MAKE) mod
|
||||
|
||||
RELEASE_BRANCH:=release/4.0
|
||||
RELEASE_BRANCH:=release/4.0.4
|
||||
GOPROXY ?= direct
|
||||
|
||||
mod:
|
||||
|
||||
@@ -79,6 +79,8 @@ echo "# Yunion RPM spec
|
||||
%global pkgname yunion-$PKG
|
||||
%global homedir /var/run/%{owner}
|
||||
%global use_systemd $SERVICE
|
||||
%global debug_package %{nil}
|
||||
%global __strip /usr/bin/eu-strip
|
||||
|
||||
Name: %{pkgname}
|
||||
Version: $VERSION
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM registry.cn-beijing.aliyuncs.com/yunionio/ansibleserver-base:3.22.2-0
|
||||
|
||||
# https://github.com/yunionio/telegraf/blob/yn-release-1.36/Makefile.yn#L28
|
||||
# install playbook and telegraf install pkg
|
||||
COPY --from=registry.cn-beijing.aliyuncs.com/yunionio/file-repo:v0.5.3 /opt/yunion/playbook /opt/yunion/playbook
|
||||
COPY --from=registry.cn-beijing.aliyuncs.com/yunionio/file-repo:v0.5.3 /opt/yunion/ansible-install-pkg /opt/yunion/ansible-install-pkg
|
||||
COPY --from=registry.cn-beijing.aliyuncs.com/yunionio/file-repo:v0.5.5 /opt/yunion/playbook /opt/yunion/playbook
|
||||
COPY --from=registry.cn-beijing.aliyuncs.com/yunionio/file-repo:v0.5.5 /opt/yunion/ansible-install-pkg /opt/yunion/ansible-install-pkg
|
||||
|
||||
ADD ./_output/alpine-build/bin/ansibleserver /opt/yunion/bin/ansibleserver
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM registry.cn-beijing.aliyuncs.com/yunionio/baremetal-base:v0.3.9-20251215.0
|
||||
FROM registry.cn-beijing.aliyuncs.com/yunionio/baremetal-base:v0.3.9-20260724.0
|
||||
|
||||
LABEL maintainer="Zexi Li <lizexi@yunionyun.com>"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#RUN yum install -y https://iso.yunion.cn/vm-images/baremetal-pxerom-1.1.0-21092209.x86_64.rpm
|
||||
#RUN yum install -y http://192.168.23.50:8083/baremetal-pxerom-1.1.0-21092209.x86_64.rpm
|
||||
FROM registry.cn-beijing.aliyuncs.com/yunionio/yunionos:v4.0.0-20251201.0 as yunionos
|
||||
FROM registry.cn-beijing.aliyuncs.com/yunionio/yunionos:v4.0.0-20251203.0 as yunionos
|
||||
|
||||
FROM centos:8 as grub-stage
|
||||
FROM registry.cn-beijing.aliyuncs.com/cloudpods/centos:8 as grub-stage
|
||||
|
||||
RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*
|
||||
RUN sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
REGISTRY ?= "registry.cn-beijing.aliyuncs.com/yunionio"
|
||||
DOCKER_BUILD = docker build -t $(REGISTRY)
|
||||
DOCKER_BUILDX = docker buildx build --platform linux/arm64,linux/amd64,linux/riscv64 --push -t $(REGISTRY)
|
||||
DOCKER_BUILDX_BM = docker buildx build --platform linux/arm64,linux/amd64 --push -t $(REGISTRY)
|
||||
|
||||
|
||||
debian10-base:
|
||||
@@ -40,7 +41,7 @@ WEBCONSOLE_BASE_VERSION_3-22-2 = 3.22.2-1
|
||||
webconsole-base:
|
||||
$(DOCKER_BUILDX)/webconsole-base:$(WEBCONSOLE_BASE_VERSION_3-22-2) -f ./Dockerfile.webconsole-base .
|
||||
|
||||
BAREMETAL_BASE_VERSION = v0.3.9-20251112.1
|
||||
BAREMETAL_BASE_VERSION = v0.3.9-20260724.0
|
||||
|
||||
FEDORA_RISCV64_VERSION = 42
|
||||
fedora-riscv64-base:
|
||||
@@ -53,8 +54,9 @@ baremetal-base-riscv: fedora-riscv64-base
|
||||
$(DOCKER_BUILDX)/baremetal-base:$(BAREMETAL_BASE_VERSION) -f ./Dockerfile.baremetal-base-riscv .
|
||||
#docker push $(REGISTRY)/baremetal-base:$(BAREMETAL_BASE_VERSION)
|
||||
|
||||
# TODO: support riscv64 for baremetal-base
|
||||
baremetal-base:
|
||||
$(DOCKER_BUILDX)/baremetal-base:$(BAREMETAL_BASE_VERSION) -f ./Dockerfile.baremetal-base .
|
||||
$(DOCKER_BUILDX_BM)/baremetal-base:$(BAREMETAL_BASE_VERSION) -f ./Dockerfile.baremetal-base .
|
||||
#docker push $(REGISTRY)/baremetal-base:$(BAREMETAL_BASE_VERSION)
|
||||
|
||||
TORRENT_VERSION = 20210815.0
|
||||
|
||||
@@ -17,6 +17,7 @@ package aiproxy
|
||||
import (
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
apmodules "yunion.io/x/onecloud/pkg/mcclient/modules/aiproxy"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/options"
|
||||
apoptions "yunion.io/x/onecloud/pkg/mcclient/options/aiproxy"
|
||||
)
|
||||
|
||||
@@ -28,5 +29,7 @@ func init() {
|
||||
cmd.Update(new(apoptions.AiProviderUpdateOptions))
|
||||
cmd.Delete(new(apoptions.AiProviderDeleteOptions))
|
||||
cmd.PerformClass("test-connectivity", new(apoptions.AiProviderTestConnectivityOptions))
|
||||
cmd.Perform("test-connectivity", new(options.BaseIdOptions))
|
||||
cmd.Perform("set-models", new(apoptions.AiProviderSetModelsOptions))
|
||||
registerEnableDisable(cmd)
|
||||
}
|
||||
|
||||
55
cmd/climc/shell/aiproxy/ai_proxy_usage.go
Normal file
55
cmd/climc/shell/aiproxy/ai_proxy_usage.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// 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 aiproxy
|
||||
|
||||
import (
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
|
||||
apmodules "yunion.io/x/onecloud/pkg/mcclient/modules/aiproxy"
|
||||
apoptions "yunion.io/x/onecloud/pkg/mcclient/options/aiproxy"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&apmodules.AIProxyUsage)
|
||||
cmd.ClassShow(new(apoptions.AiProxyUsageOverviewOptions))
|
||||
cmd.ClassShow(new(apoptions.AiProxyUsageAnalysisOptions))
|
||||
cmd.RunWithDesc("event-list", "List ai_proxy_usage events", new(apoptions.AiProxyUsageEventListOptions),
|
||||
func(s *mcclient.ClientSession, args *apoptions.AiProxyUsageEventListOptions) error {
|
||||
params, err := args.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret, err := apmodules.AIProxyUsage.Get(s, args.GetId(), params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shell.PrintList(modulebase.JSON2ListResult(ret), []string{
|
||||
"id",
|
||||
"timestamp",
|
||||
"model",
|
||||
"provider",
|
||||
"source",
|
||||
"api_key_label",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"latency_ms",
|
||||
"result",
|
||||
"status_code",
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -56,6 +56,9 @@ func init() {
|
||||
})
|
||||
R(&options.MeshNetworkUpdateOptions{}, "meshnetwork-update", "Update mesh network", func(s *mcclient.ClientSession, opts *options.MeshNetworkUpdateOptions) error {
|
||||
params, err := base_options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mn, err := modules.MeshNetworks.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -56,6 +56,9 @@ func init() {
|
||||
})
|
||||
R(&options.RouterUpdateOptions{}, "router-update", "Update router", func(s *mcclient.ClientSession, opts *options.RouterUpdateOptions) error {
|
||||
params, err := base_options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router, err := modules.Routers.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -56,6 +56,9 @@ func init() {
|
||||
})
|
||||
R(&options.RouteUpdateOptions{}, "router-route-update", "Update router route", func(s *mcclient.ClientSession, opts *options.RouteUpdateOptions) error {
|
||||
params, err := base_options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router, err := modules.Routes.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -56,6 +56,9 @@ func init() {
|
||||
})
|
||||
R(&options.RuleUpdateOptions{}, "router-rule-update", "Update router rule", func(s *mcclient.ClientSession, opts *options.RuleUpdateOptions) error {
|
||||
params, err := base_options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router, err := modules.Rules.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -58,7 +58,7 @@ func init() {
|
||||
|
||||
type HostNetworkDetailOptions struct {
|
||||
HOST string `help:"ID or Name of Host"`
|
||||
NETWORK string `help:"ID or Name of Wire"`
|
||||
NETWORK string `help:"ID or Name of Network"`
|
||||
}
|
||||
R(&HostNetworkDetailOptions{}, "host-network-show", "Show baremetal network details", func(s *mcclient.ClientSession, args *HostNetworkDetailOptions) error {
|
||||
result, err := modules.Baremetalnetworks.Get(s, args.HOST, args.NETWORK, nil)
|
||||
|
||||
@@ -24,7 +24,7 @@ func init() {
|
||||
|
||||
cmd := shell.NewResourceCmd(&modules.Cloudaccounts).WithKeyword("cloud-account")
|
||||
cmd.List(&options.CloudaccountListOptions{})
|
||||
cmd.Show(&options.SCloudAccountIdOptions{})
|
||||
cmd.Show(&options.CloudaccountShowOptions{})
|
||||
cmd.Delete(&options.SCloudAccountIdOptions{})
|
||||
cmd.Update(&options.SCloudAccountUpdateBaseOptions{})
|
||||
// cmd.PerformClassWithKeyword("preparenets-vmware", "prepare-nets", &options.SVMwareCloudAccountPrepareNetsOptions{})
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
func init() {
|
||||
type CloudproviderRegionListOptions struct {
|
||||
options.BaseListOptions
|
||||
Region string `help:"ID or Name of Host"`
|
||||
Region string `help:"ID or Name of Region"`
|
||||
|
||||
Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event"`
|
||||
}
|
||||
|
||||
@@ -34,16 +34,18 @@ func init() {
|
||||
cmd.Perform("purge", &compute.CloudregionPurgeOptions{})
|
||||
|
||||
type CloudregionListOptions struct {
|
||||
_ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。指定云厂商时必须传 provider,例如创建阿里云:provider=[\"Aliyun\"]。创建虚拟机时必须 usable=true(MCP 默认注入)。查完后继续 capability/镜像/sku,最后 climc_server_create。严禁只查区域后停止"`
|
||||
|
||||
options.BaseListOptions
|
||||
|
||||
Usable *bool `help:"List regions where networks are usable"`
|
||||
UsableVpc *bool `help:"List regions where VPC are usable"`
|
||||
Service string `help:"List regions which service has available skus" choices:"dbinstances|servers|elasticcaches"`
|
||||
Usable *bool `help:"只列网络可用的区域;创建虚拟机时必须为 true(MCP 默认注入 usable=true)" mcp:"true"`
|
||||
UsableVpc *bool `help:"List regions where VPC are usable" mcp:"true"`
|
||||
Service string `help:"List regions which service has available skus" choices:"dbinstances|servers|elasticcaches" mcp:"true"`
|
||||
ReadOnly *bool `help:"List regions with read only account"`
|
||||
|
||||
City string `help:"List regions in the specified city"`
|
||||
City string `help:"List regions in the specified city" mcp:"true"`
|
||||
|
||||
Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event"`
|
||||
Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event" mcp:"true"`
|
||||
|
||||
DistinctField string `help:"list the specified distinct field, e.g. city, region"`
|
||||
|
||||
@@ -149,7 +151,7 @@ func init() {
|
||||
Desc string `help:"Description" json:"description" token:"desc"`
|
||||
Latitude float32 `help:"region geographical location - latitude"`
|
||||
Longitude float32 `help:"region geographical location - longitude"`
|
||||
City string `help:"region geograpical location - city, e.g. Beijing, Frankfurt"`
|
||||
City string `help:"region geographical location - city, e.g. Beijing, Frankfurt"`
|
||||
CountryCode string `help:"region geographical location - ISO country code, e.g. CN"`
|
||||
}
|
||||
R(&CloudregionCreateOptions{}, "cloud-region-create", "Create a cloud region", func(s *mcclient.ClientSession, args *CloudregionCreateOptions) error {
|
||||
@@ -192,7 +194,7 @@ func init() {
|
||||
Desc string `help:"Description of the region" json:"description" token:"desc"`
|
||||
Latitude float32 `help:"region geographical location - latitude"`
|
||||
Longitude float32 `help:"region geographical location - longitude"`
|
||||
City string `help:"region geograpical location - city, e.g. Beijing, Frankfurt"`
|
||||
City string `help:"region geographical location - city, e.g. Beijing, Frankfurt"`
|
||||
CountryCode string `help:"region geographical location - ISO country code, e.g. CN"`
|
||||
}
|
||||
R(&CloudregionUpdateOptions{}, "cloud-region-update", "Update a cloud region", func(s *mcclient.ClientSession, args *CloudregionUpdateOptions) error {
|
||||
@@ -224,13 +226,18 @@ func init() {
|
||||
})
|
||||
|
||||
type CloudregionCapabiltyOptions struct {
|
||||
ID string `help:"ID or name of cloud region to check" json:"-"`
|
||||
Domain string `help:"cloud region domain"`
|
||||
_ struct{} `mcp-desc:"【创建流程中的中间步骤】查询区域能力,尤其是可用磁盘存储类型(storage_types2 / system_storage_types)。公有云创建前必须调用:ID 用 climc_cloud_region_list 返回的 id。从返回中选系统盘 backend(如 cloud_essd、cloud_ssd),写入 disk 的 backend=。查完继续镜像/网络/sku,最后 climc_server_create"`
|
||||
|
||||
ID string `help:"ID or name of cloud region to check;必须用 cloud-region-list 的 id" json:"-" mcp:"required"`
|
||||
Domain string `help:"cloud region domain" mcp:"true"`
|
||||
|
||||
ShowEmulated bool `help:"show emulated cloud region"`
|
||||
}
|
||||
R(&CloudregionCapabiltyOptions{}, "cloud-region-capability", "Show region's capacibilities", func(s *mcclient.ClientSession, args *CloudregionCapabiltyOptions) error {
|
||||
query, err := options.StructToParams(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := modules.Cloudregions.GetSpecific(s, args.ID, "capability", query)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
R = shell.R
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
printBatchResults = printutils.PrintJSONBatchResults
|
||||
printBatchResults = shell.PrintBatchResults
|
||||
|
||||
InvalidUpdateError = shell.InvalidUpdateError
|
||||
printObjectRecursive = printutils.PrintJSONObjectRecursive
|
||||
|
||||
@@ -29,7 +29,7 @@ func init() {
|
||||
cmd.List(&compute.DBInstanceListOptions{})
|
||||
cmd.Create(&compute.DBInstanceCreateOptions{})
|
||||
cmd.Update(&compute.DBInstanceUpdateOptions{})
|
||||
cmd.Show(&compute.DBInstanceIdOptions{})
|
||||
cmd.Show(&compute.DBInstanceShowOptions{})
|
||||
cmd.Delete(&compute.DBInstanceDeleteOptions{})
|
||||
cmd.Perform("renew", &compute.DBInstanceRenewOptions{})
|
||||
cmd.Perform("change-config", &compute.DBInstanceChangeConfigOptions{})
|
||||
|
||||
@@ -42,7 +42,7 @@ import (
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.Disks)
|
||||
cmd.List(&compute_options.DiskListOptions{})
|
||||
cmd.Show(&compute_options.DiskIdOptions{})
|
||||
cmd.Show(&compute_options.DiskShowOptions{})
|
||||
cmd.Perform("public", &compute_options.DiskIdOptions{})
|
||||
cmd.Perform("private", &compute_options.DiskIdOptions{})
|
||||
cmd.Perform("syncstatus", &compute_options.DiskIdOptions{})
|
||||
@@ -55,13 +55,7 @@ func init() {
|
||||
cmd.Perform("change-billing-type", new(compute_options.DiskChangeBillingTypeOptions))
|
||||
cmd.Perform("change-storage-type", &compute_options.DiskChangeStorageTypeOptions{})
|
||||
|
||||
type DiskDeleteOptions struct {
|
||||
ID []string `help:"ID of disks to delete" metavar:"DISK"`
|
||||
OverridePendingDelete bool `help:"Delete disk directly instead of pending delete" short-token:"f"`
|
||||
DeleteSnapshots bool `help:"Delete disk snapshots before delete disk"`
|
||||
}
|
||||
|
||||
R(&DiskDeleteOptions{}, "disk-delete", "Delete a disk", func(s *mcclient.ClientSession, args *DiskDeleteOptions) error {
|
||||
R(&compute_options.DiskDeleteOptions{}, "disk-delete", "Delete a disk", func(s *mcclient.ClientSession, args *compute_options.DiskDeleteOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
if args.OverridePendingDelete {
|
||||
params.Add(jsonutils.JSONTrue, "override_pending_delete")
|
||||
@@ -100,7 +94,7 @@ func init() {
|
||||
AutoSnapshot string `help:"enable/disable auto snapshot of disk" choices:"enable|disable"`
|
||||
DiskType string `help:"Disk type" choices:"data|volume|sys"`
|
||||
IsSsd *bool `help:"mark disk as ssd" negative:"no-is-ssd"`
|
||||
AutoReset *bool `help:"Enable auto reset disk after geust shutdown"`
|
||||
AutoReset *bool `help:"Enable auto reset disk after guest shutdown"`
|
||||
}
|
||||
R(&DiskUpdateOptions{}, "disk-update", "Update property of a virtual disk", func(s *mcclient.ClientSession, args *DiskUpdateOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
@@ -173,11 +167,7 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
|
||||
type DiskResizeOptions struct {
|
||||
DISK string `help:"ID or name of disk"`
|
||||
SIZE string `help:"Size of disk"`
|
||||
}
|
||||
R(&DiskResizeOptions{}, "disk-resize", "Resize a disk", func(s *mcclient.ClientSession, args *DiskResizeOptions) error {
|
||||
R(&compute_options.DiskResizeOptions{}, "disk-resize", "Resize a disk", func(s *mcclient.ClientSession, args *compute_options.DiskResizeOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
params.Add(jsonutils.NewString(args.SIZE), "size")
|
||||
disk, err := modules.Disks.PerformAction(s, args.DISK, "resize", params)
|
||||
@@ -189,7 +179,7 @@ func init() {
|
||||
})
|
||||
type DiskResetOptions struct {
|
||||
DISK string `help:"ID or name of disk"`
|
||||
SNAPSHOT string `help:"snapshots ID of disk"`
|
||||
SNAPSHOT string `help:"Snapshot ID of disk"`
|
||||
AutoStart bool `help:"Autostart guest"`
|
||||
}
|
||||
R(&DiskResetOptions{}, "disk-reset", "Resize a disk", func(s *mcclient.ClientSession, args *DiskResetOptions) error {
|
||||
@@ -245,9 +235,9 @@ func init() {
|
||||
|
||||
type DiskChangeOwnerOptions struct {
|
||||
ID string `help:"Disk to change owner" json:"-"`
|
||||
PROJECT string `help:"Project ID or change" json:"tenant"`
|
||||
PROJECT string `help:"Project ID or name" json:"tenant"`
|
||||
}
|
||||
R(&DiskChangeOwnerOptions{}, "disk-change-owner", "Change owner porject of a disk", func(s *mcclient.ClientSession, opts *DiskChangeOwnerOptions) error {
|
||||
R(&DiskChangeOwnerOptions{}, "disk-change-owner", "Change owner project of a disk", func(s *mcclient.ClientSession, opts *DiskChangeOwnerOptions) error {
|
||||
params, err := options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.ElasticCache).WithKeyword("elastic-cache")
|
||||
cmd.List(&compute.ElasticCacheListOptions{})
|
||||
cmd.Show(&compute.ElasticCacheIdOption{})
|
||||
cmd.Show(&compute.ElasticCacheShowOptions{})
|
||||
cmd.Create(&compute.ElasticCacheCreateOptions{})
|
||||
cmd.Delete(&compute.ElasticCacheIdOption{})
|
||||
cmd.Perform("restart", &compute.ElasticCacheIdOption{})
|
||||
|
||||
@@ -27,7 +27,7 @@ func init() {
|
||||
cmd.Create(&compute.EipCreateOptions{})
|
||||
cmd.Delete(&options.BaseIdOptions{})
|
||||
cmd.Update(&compute.EipUpdateOptions{})
|
||||
cmd.Show(&options.BaseShowOptions{})
|
||||
cmd.Show(&compute.EipShowOptions{})
|
||||
cmd.Perform("purge", &options.BaseIdOptions{})
|
||||
cmd.Perform("associate", &compute.EipAssociateOptions{})
|
||||
cmd.Perform("dissociate", &compute.EipDissociateOptions{})
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.Hosts)
|
||||
cmd.List(&compute.HostListOptions{})
|
||||
cmd.List(&compute.HostListForMcpOptions{})
|
||||
cmd.GetMetadata(&options.BaseIdOptions{})
|
||||
cmd.GetProperty(&compute.HostStatusStatisticsOptions{})
|
||||
cmd.Update(&compute.HostUpdateOptions{})
|
||||
|
||||
31
cmd/climc/shell/compute/ipsets.go
Normal file
31
cmd/climc/shell/compute/ipsets.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// 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 compute
|
||||
|
||||
import (
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/options/compute"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.IpSets)
|
||||
cmd.List(&compute.IpSetListOptions{})
|
||||
cmd.Show(&compute.IpSetIdOptions{})
|
||||
cmd.Create(&compute.IpSetCreateOptions{})
|
||||
cmd.Update(&compute.IpSetUpdateOptions{})
|
||||
cmd.Delete(&compute.IpSetIdOptions{})
|
||||
cmd.Perform("syncstatus", &compute.IpSetIdOptions{})
|
||||
}
|
||||
@@ -26,8 +26,12 @@ func init() {
|
||||
cmd.List(&compute.DeviceListOptions{})
|
||||
cmd.Update(&compute.DeviceUpdateOptions{})
|
||||
cmd.Show(&compute.DeviceShowOptions{})
|
||||
cmd.Create(&compute.DeviceCreateOptions{})
|
||||
cmd.BatchDelete(&compute.DeviceDeleteOptions{})
|
||||
cmd.BatchPerform("purge", &compute.DevicePurgeOptions{})
|
||||
cmd.Perform("public", &options.SharableResourcePublicOptions{})
|
||||
cmd.Perform("private", &options.BaseIdOptions{})
|
||||
|
||||
scmd := shell.NewResourceCmd(&modules.ServerIsolatedDevices)
|
||||
scmd.List(&compute.ServerDeviceListOptions{})
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.Kafkas)
|
||||
cmd.List(&compute.KafkaListOptions{})
|
||||
cmd.Update(&compute.KafkaUpdateOptions{})
|
||||
cmd.Show(&compute.KafkaIdOption{})
|
||||
cmd.Show(&compute.KafkaShowOptions{})
|
||||
cmd.Get("topics", &options.BaseIdOptions{})
|
||||
cmd.Delete(&compute.KafkaIdOption{})
|
||||
cmd.Perform("syncstatus", &compute.KafkaIdOption{})
|
||||
|
||||
@@ -97,6 +97,9 @@ func init() {
|
||||
})
|
||||
R(&options.LoadbalancerAgentUpdateOptions{}, "lbagent-update", "Update lbagent", func(s *mcclient.ClientSession, opts *options.LoadbalancerAgentUpdateOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lbagent, err := modules.LoadbalancerAgents.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -55,6 +55,9 @@ func init() {
|
||||
})
|
||||
R(&options.LoadbalancerBackendUpdateOptions{}, "lbbackend-update", "Update lbbackend", func(s *mcclient.ClientSession, opts *options.LoadbalancerBackendUpdateOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lbbackend, err := modules.LoadbalancerBackends.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,6 +54,9 @@ func init() {
|
||||
})
|
||||
R(&options.LoadbalancerListenerRuleUpdateOptions{}, "lblistenerrule-update", "Update lblistenerrule", func(s *mcclient.ClientSession, opts *options.LoadbalancerListenerRuleUpdateOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lblistenerrule, err := modules.LoadbalancerListenerRules.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -87,6 +87,9 @@ func init() {
|
||||
})
|
||||
R(&options.LoadbalancerListenerUpdateOptions{}, "lblistener-update", "Update lblistener", func(s *mcclient.ClientSession, opts *options.LoadbalancerListenerUpdateOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lblistener, err := modules.LoadbalancerListeners.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -25,7 +25,7 @@ func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.MongoDB)
|
||||
cmd.List(&compute.MongoDBListOptions{})
|
||||
cmd.Update(&compute.MongoDBUpdateOptions{})
|
||||
cmd.Show(&options.BaseIdOptions{})
|
||||
cmd.Show(&compute.MongoDBShowOptions{})
|
||||
cmd.Delete(&options.BaseIdOptions{})
|
||||
cmd.Get("backups", &options.BaseIdOptions{})
|
||||
cmd.Perform("syncstatus", &options.BaseIdOptions{})
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
func init() {
|
||||
|
||||
type NetworkinterfaceNetworkListOptions struct {
|
||||
Networkinterface string `help:"ID or Name of Server"`
|
||||
Networkinterface string `help:"ID or Name of Networkinterface"`
|
||||
Network string `help:"Network ID or name"`
|
||||
}
|
||||
R(&NetworkinterfaceNetworkListOptions{}, "networkinterface-network-list", "List server network pairs", func(s *mcclient.ClientSession, args *NetworkinterfaceNetworkListOptions) error {
|
||||
|
||||
@@ -212,7 +212,7 @@ func init() {
|
||||
|
||||
type NetworkChangeOwnerOptions struct {
|
||||
ID string `help:"Network to change owner" json:"-"`
|
||||
PROJECT string `help:"Project ID or change" json:"tenant"`
|
||||
PROJECT string `help:"Project ID or name" json:"tenant"`
|
||||
}
|
||||
R(&NetworkChangeOwnerOptions{}, "network-change-owner", "Change owner project of a network", func(s *mcclient.ClientSession, args *NetworkChangeOwnerOptions) error {
|
||||
params, err := options.StructToParams(args)
|
||||
|
||||
@@ -57,6 +57,9 @@ func init() {
|
||||
})
|
||||
R(&options.ProxySettingUpdateOptions{}, "proxysetting-update", "Update proxysetting", func(s *mcclient.ClientSession, opts *options.ProxySettingUpdateOptions) error {
|
||||
params, err := options.StructToParams(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxysetting, err := modules.ProxySettings.Update(s, opts.ID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -24,10 +24,10 @@ import (
|
||||
|
||||
func init() {
|
||||
type NetworkReserveIPOptions struct {
|
||||
NETWORK string `help:"IP or name of network"`
|
||||
NETWORK string `help:"ID or name of network"`
|
||||
NOTES string `help:"Why reserve this IP"`
|
||||
IPS []string `help:"IPs to reserve"`
|
||||
Duration string `help:"reservation duration, e.g. 1I, 1H, 2M"`
|
||||
Duration string `help:"reservation duration, e.g. 1D, 1H, 2M"`
|
||||
Status string `help:"ip status"`
|
||||
}
|
||||
R(&NetworkReserveIPOptions{}, "network-reserve-ip", "Reserve an IP address from pool", func(s *mcclient.ClientSession, args *NetworkReserveIPOptions) error {
|
||||
@@ -49,7 +49,7 @@ func init() {
|
||||
})
|
||||
|
||||
type NetworkReleaseReservedIPOptions struct {
|
||||
NETWORK string `help:"IP or name of network"`
|
||||
NETWORK string `help:"ID or name of network"`
|
||||
IP string `help:"IP to release"`
|
||||
}
|
||||
R(&NetworkReleaseReservedIPOptions{}, "network-release-reserved-ip", "Release a reserved IP into pool", func(s *mcclient.ClientSession, args *NetworkReleaseReservedIPOptions) error {
|
||||
|
||||
@@ -49,6 +49,9 @@ func init() {
|
||||
R(&ScalingPolicyListOptions{}, "scaling-policy-list", "List Scaling Policy", func(s *mcclient.ClientSession,
|
||||
args *ScalingPolicyListOptions) error {
|
||||
params, err := options.ListStructToParams(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
policies, err := modules.ScalingPolicy.List(s, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,15 +17,14 @@ package compute
|
||||
import (
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/options"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/options/compute"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.SecGroupRules).WithKeyword("secgroup-rule")
|
||||
cmd.List(&compute.SecGroupRulesListOptions{})
|
||||
cmd.Show(&options.BaseShowOptions{})
|
||||
cmd.Delete(&options.BaseIdOptions{})
|
||||
cmd.Show(&compute.SecGroupRuleShowOptions{})
|
||||
cmd.Delete(&compute.SecGroupRuleDeleteOptions{})
|
||||
cmd.Create(&compute.SecGroupRulesCreateOptions{})
|
||||
cmd.Update(&compute.SecGroupRulesUpdateOptions{})
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ func init() {
|
||||
cmd := shell.NewResourceCmd(&modules.SecGroups)
|
||||
cmd.List(&options.SecgroupListOptions{})
|
||||
cmd.Create(&options.SecgroupCreateOptions{})
|
||||
cmd.Show(&options.SecgroupIdOptions{})
|
||||
cmd.Show(&options.SecgroupShowOptions{})
|
||||
cmd.Update(&baseoptions.BaseUpdateOptions{})
|
||||
cmd.Delete(&options.SecgroupIdOptions{})
|
||||
cmd.Delete(&options.SecgroupDeleteOptions{})
|
||||
cmd.Perform("public", &options.SecgroupIdOptions{})
|
||||
cmd.Perform("syncstatus", &options.SecgroupIdOptions{})
|
||||
cmd.Perform("private", &options.SecgroupIdOptions{})
|
||||
|
||||
@@ -77,18 +77,10 @@ func init() {
|
||||
type ServerDiskUpdateOptions struct {
|
||||
SERVER string `help:"ID or Name of server"`
|
||||
DISK string `help:"ID or Name of Disk"`
|
||||
Cache string `help:"Cache mode of vDisk" choices:"writethrough|none|writeback|directsync"`
|
||||
Aio string `help:"Asynchronous IO mode of vDisk" choices:"native|threads"`
|
||||
Index int64 `help:"Index of vDisk" default:"-1"`
|
||||
}
|
||||
R(&ServerDiskUpdateOptions{}, "server-disk-update", "Update details of a virtual disk of a virtual server", func(s *mcclient.ClientSession, args *ServerDiskUpdateOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
if len(args.Cache) > 0 {
|
||||
params.Add(jsonutils.NewString(args.Cache), "cache_mode")
|
||||
}
|
||||
if len(args.Aio) > 0 {
|
||||
params.Add(jsonutils.NewString(args.Aio), "aio_mode")
|
||||
}
|
||||
if args.Index >= 0 {
|
||||
params.Add(jsonutils.NewInt(args.Index), "index")
|
||||
}
|
||||
@@ -121,10 +113,12 @@ func init() {
|
||||
})
|
||||
|
||||
type ServerAttachDiskOptions struct {
|
||||
_ struct{} `mcp-desc:"将已有硬盘挂载到虚机。SERVER/DISK 为 id/name;可选 driver。先 climc_server_list + climc_disk_list"`
|
||||
|
||||
SERVER string `help:"ID or name of server"`
|
||||
DISK string `help:"ID of name of disk to attach"`
|
||||
Driver string `help:"Driver" choices:"virtio|ide|scsi"`
|
||||
Cache string `help:"Cache mode" choices:"writeback|none|writethrought"`
|
||||
DISK string `help:"ID or name of disk to attach"`
|
||||
Driver string `help:"Disk driver" choices:"virtio|ide|scsi" mcp:"true"`
|
||||
Cache string `help:"Cache mode" choices:"writeback|none|writethrough" mcp:"true"`
|
||||
}
|
||||
R(&ServerAttachDiskOptions{}, "server-attach-disk", "Attach an existing virtual disks to a virtual server", func(s *mcclient.ClientSession, args *ServerAttachDiskOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
@@ -144,9 +138,11 @@ func init() {
|
||||
})
|
||||
|
||||
type ServerDetachDiskOptions struct {
|
||||
_ struct{} `mcp-desc:"从虚机卸载硬盘。SERVER/DISK 为 id/name;可选 delete-disk。先 climc_server_list / climc_disk_list"`
|
||||
|
||||
SERVER string `help:"ID or name of server"`
|
||||
DISK string `help:"ID or name of disk to detach"`
|
||||
DeleteDisk bool `help:"Delete disk if the disk not has flag of auto_delete when detached"`
|
||||
DeleteDisk bool `help:"Delete the disk after detach (ignored if disk has auto_delete)" mcp:"true"`
|
||||
}
|
||||
R(&ServerDetachDiskOptions{}, "server-detach-disk", "Detach a disk from a virtual server", func(s *mcclient.ClientSession, args *ServerDetachDiskOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
|
||||
@@ -23,15 +23,15 @@ import (
|
||||
|
||||
func init() {
|
||||
type ServerAttachDeviceOptions struct {
|
||||
SERVER string `help:"ID or name of server"`
|
||||
DEVICE string `help:"ID of isolated device to attach"`
|
||||
Type string `help:"Device type" choices:"GPU-HPC|GPU-VGA|PCI"`
|
||||
SERVER string `help:"ID or name of server"`
|
||||
DEVICE string `help:"ID of isolated device to attach"`
|
||||
GpuType string `help:"Type of GPU to attach to" choices:"HPC|VGA"`
|
||||
}
|
||||
R(&ServerAttachDeviceOptions{}, "server-attach-isolated-device", "Attach an existing isolated device to a virtual server", func(s *mcclient.ClientSession, args *ServerAttachDeviceOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
params.Add(jsonutils.NewString(args.DEVICE), "device")
|
||||
if len(args.Type) > 0 {
|
||||
params.Add(jsonutils.NewString(args.Type), "dev_type")
|
||||
if args.GpuType != "" {
|
||||
params.Add(jsonutils.NewString(args.GpuType), "gpu_type")
|
||||
}
|
||||
srv, err := modules.Servers.PerformAction(s, args.SERVER, "attach-isolated-device", params)
|
||||
if err != nil {
|
||||
@@ -44,11 +44,13 @@ func init() {
|
||||
type ServerDetachDeviceOptions struct {
|
||||
SERVER string `help:"ID or name of server"`
|
||||
DEVICE string `help:"ID of isolated device to detach"`
|
||||
INDEX int `help:"Index of isolated device to detach"`
|
||||
IsForce bool `help:"Force detach isolated device"`
|
||||
}
|
||||
R(&ServerDetachDeviceOptions{}, "server-detach-isolated-device", "Detach a isolated device from a virtual server", func(s *mcclient.ClientSession, args *ServerDetachDeviceOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
params.Add(jsonutils.NewString(args.DEVICE), "device")
|
||||
params.Add(jsonutils.NewInt(int64(args.INDEX)), "index")
|
||||
if args.IsForce {
|
||||
params.Set("is_force", jsonutils.JSONTrue)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func init() {
|
||||
|
||||
type ServerNetworkUpdateOptions struct {
|
||||
SERVER string `help:"ID or Name of Server"`
|
||||
NETWORK string `help:"ID or Name of Wire"`
|
||||
NETWORK string `help:"ID or Name of Network"`
|
||||
Mac string `help:"Mac of NIC"`
|
||||
Driver string `help:"Driver model of vNIC" choices:"virtio|e1000|vmxnet3|rtl8139"`
|
||||
Index int64 `help:"Index of NIC" default:"-1"`
|
||||
@@ -205,7 +205,7 @@ func init() {
|
||||
Reserve bool `help:"Put the release IP address into reserved address pool"`
|
||||
Force bool `help:"detach server network by force"`
|
||||
}
|
||||
R(&ServerDetachNetworkOptions{}, "server-detach-network", "Detach the virtual network fron a virtual server", func(s *mcclient.ClientSession, args *ServerDetachNetworkOptions) error {
|
||||
R(&ServerDetachNetworkOptions{}, "server-detach-network", "Detach the virtual network from a virtual server", func(s *mcclient.ClientSession, args *ServerDetachNetworkOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
// params.Add(jsonutils.NewString(args.NETWORK), "net_id")
|
||||
if args.Reserve {
|
||||
|
||||
@@ -127,8 +127,9 @@ func init() {
|
||||
cmd.Perform("update-sub-ips", &options.ServerUpdateSubIpsOptions{})
|
||||
cmd.BatchPerform("restore-virtual-isolated-devices", &options.ServerIdsOptions{})
|
||||
cmd.BatchPerform("set-os-info", &options.ServerSetOSInfoOptions{})
|
||||
cmd.BatchPerform("start-rescue", &options.ServerStartOptions{})
|
||||
cmd.BatchPerform("stop-rescue", &options.ServerStartOptions{})
|
||||
// 与 server-start 复用参数时单独包一层,避免继承 mcp-desc 被注册为 MCP tool
|
||||
cmd.BatchPerform("start-rescue", &options.ServerStartRescueOptions{})
|
||||
cmd.BatchPerform("stop-rescue", &options.ServerStopRescueOptions{})
|
||||
cmd.BatchPerform("sync-os-info", &options.ServerIdsOptions{})
|
||||
cmd.BatchPerform("set-root-disk-matcher", &options.ServerSetRootDiskMatcher{})
|
||||
cmd.Perform("disable-auto-merge-snapshot", &options.ServerDisableAutoMergeSnapshot{})
|
||||
@@ -293,7 +294,7 @@ func init() {
|
||||
|
||||
type ServerDiskSnapshotOptions struct {
|
||||
SERVER string `help:"server ID or Name"`
|
||||
DISK string `help:"create snapshot disk id"`
|
||||
DISK string `help:"Disk ID for snapshot creation"`
|
||||
SNAPSHOTNAME string `help:"Snapshot name"`
|
||||
}
|
||||
R(&ServerDiskSnapshotOptions{}, "server-disk-create-snapshot", "Task server disk snapshot", func(s *mcclient.ClientSession, args *ServerDiskSnapshotOptions) error {
|
||||
@@ -733,7 +734,7 @@ func init() {
|
||||
|
||||
type ServerSnapshotAndClone struct {
|
||||
ID string `help:"ID or name of VM" json:"-"`
|
||||
NAME string `help:"Newly instance name" json:"name"`
|
||||
NAME string `help:"New instance name" json:"name"`
|
||||
AutoStart bool `help:"Auto start new guest"`
|
||||
AllowDelete bool `help:"Allow new guest delete" json:"-"`
|
||||
Count int `help:"Guest count"`
|
||||
@@ -772,7 +773,7 @@ func init() {
|
||||
ID string `help:"ID or name of server"`
|
||||
Save string `help:"save xml into this file"`
|
||||
}
|
||||
R(&ServerJnlpOptions{}, "server-jnlp", "Get baremetal server jnlp file contentn", func(s *mcclient.ClientSession, args *ServerJnlpOptions) error {
|
||||
R(&ServerJnlpOptions{}, "server-jnlp", "Get baremetal server jnlp file content", func(s *mcclient.ClientSession, args *ServerJnlpOptions) error {
|
||||
spec, err := modules.Servers.GetSpecific(s, args.ID, "jnlp", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -32,7 +32,7 @@ func init() {
|
||||
HostType string `help:"Host type filter" choices:"baremetal|hypervisor|esxi|kubelet|hyperv"`
|
||||
Gpu bool `help:"Only show gpu devices"`
|
||||
Zone string `help:"Filter by zone id or name"`
|
||||
Occupied bool `help:"show occupid host" json:"-"`
|
||||
Occupied bool `help:"show occupied host" json:"-"`
|
||||
}
|
||||
R(&ListOptions{}, "spec", "List all kinds of model specs", func(s *mcclient.ClientSession, args *ListOptions) error {
|
||||
var params *jsonutils.JSONDict
|
||||
@@ -76,7 +76,7 @@ func init() {
|
||||
DiskSpec []string `help:"Disk spec string, like 'Linux_adapter0_HDD_111Gx4'"`
|
||||
Nic int64 `help:"#Nics count of host" metavar:"<NIC_COUNT>"`
|
||||
GpuModel []string `help:"GPU model, like 'GeForce GTX 1050 Ti'"`
|
||||
Occupied bool `help:"Show occupid host" json:"-"`
|
||||
Occupied bool `help:"Show occupied host" json:"-"`
|
||||
Manufacture string `help:"Manufacture of host"`
|
||||
Model string `help:"Model of host"`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ func init() {
|
||||
}
|
||||
var result *printutils.ListResult
|
||||
result, err = modules.DevToolCronjobs.List(s, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printList(result, modules.DevToolCronjobs.GetColumns(s))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -50,6 +50,9 @@ func init() {
|
||||
}
|
||||
var result *printutils.ListResult
|
||||
result, err = modules.DevToolTemplates.List(s, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printList(result, modules.DevToolTemplates.GetColumns(s))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -25,15 +25,15 @@ import (
|
||||
)
|
||||
|
||||
type BaseEventListOptions struct {
|
||||
Scope string `help:"scope" choices:"project|domain|system"`
|
||||
Scope string `help:"RBAC scope" choices:"project|domain|system"`
|
||||
Since string `help:"Show logs since specific date" metavar:"DATETIME"`
|
||||
Until string `help:"Show logs until specific date" metavar:"DATETIME"`
|
||||
Limit int64 `help:"Limit number of logs" default:"20"`
|
||||
Offset int64 `help:"Offset"`
|
||||
Offset int64 `help:"Offset for pagination"`
|
||||
Ascending bool `help:"Ascending order"`
|
||||
Descending bool `help:"Descending order"`
|
||||
OrderBy string `help:"order by specific field"`
|
||||
Action []string `help:"Log action"`
|
||||
Action []string `help:"Filter by log action"`
|
||||
|
||||
User string `help:"filter by operator user"`
|
||||
Project string `help:"filter by operator user's project"`
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
|
||||
var (
|
||||
R = shell.R
|
||||
printList = printutils.PrintJSONList
|
||||
printObject = printutils.PrintJSONObject
|
||||
printBatchResults = printutils.PrintJSONBatchResults
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
printBatchResults = shell.PrintBatchResults
|
||||
|
||||
InvalidUpdateError = shell.InvalidUpdateError
|
||||
printObjectRecursive = printutils.PrintJSONObjectRecursive
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
|
||||
func init() {
|
||||
type CredentialListOptions struct {
|
||||
Scope string `help:"scope" choices:"project|domain|system"`
|
||||
Scope string `help:"RBAC scope" choices:"project|domain|system"`
|
||||
Type string `help:"credential type" choices:"totp|recovery_secret|aksk|enc_key|container_image"`
|
||||
User string `help:"filter by user"`
|
||||
UserDomain string `help:"the domain of user"`
|
||||
|
||||
@@ -32,10 +32,12 @@ func init() {
|
||||
cmd.GetProperty(&identity_options.DomainGetPropertyTagValuePairOptions{})
|
||||
cmd.GetProperty(&identity_options.DomainGetPropertyTagValueTreeOptions{})
|
||||
|
||||
type DomainDetailOptions struct {
|
||||
type DomainShowOptions struct {
|
||||
_ struct{} `mcp-desc:"查询域详情。ID 可用 climc_domain_list 返回的 id/name"`
|
||||
|
||||
ID string `help:"ID or domain"`
|
||||
}
|
||||
R(&DomainDetailOptions{}, "domain-show", "Show detail of domain", func(s *mcclient.ClientSession, args *DomainDetailOptions) error {
|
||||
R(&DomainShowOptions{}, "domain-show", "Show detail of domain", func(s *mcclient.ClientSession, args *DomainShowOptions) error {
|
||||
result, err := modules.Domains.Get(s, args.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -43,7 +45,12 @@ func init() {
|
||||
printObject(result)
|
||||
return nil
|
||||
})
|
||||
R(&DomainDetailOptions{}, "domain-delete", "Delete a domain", func(s *mcclient.ClientSession, args *DomainDetailOptions) error {
|
||||
type DomainDeleteOptions struct {
|
||||
_ struct{} `mcp-desc:"删除域。若尚不知 id,先用 climc_domain_list 定位;确认域下无项目/用户后再删"`
|
||||
|
||||
ID string `help:"ID or domain"`
|
||||
}
|
||||
R(&DomainDeleteOptions{}, "domain-delete", "Delete a domain", func(s *mcclient.ClientSession, args *DomainDeleteOptions) error {
|
||||
objId, err := modules.Domains.GetId(s, args.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -72,12 +79,14 @@ func init() {
|
||||
}) */
|
||||
|
||||
type DomainCreateOptions struct {
|
||||
NAME string `help:"Name of domain"`
|
||||
Desc string `help:"Description"`
|
||||
Enabled bool `help:"Set the domain enabled"`
|
||||
Disabled bool `help:"Set the domain disabled"`
|
||||
_ struct{} `mcp-desc:"创建域。NAME 必填;可选 displayname/desc/enabled"`
|
||||
|
||||
Displayname string `help:"display name"`
|
||||
NAME string `help:"Name of domain"`
|
||||
Desc string `help:"Description" mcp:"true"`
|
||||
Enabled bool `help:"Set the domain enabled" mcp:"true"`
|
||||
Disabled bool `help:"Set the domain disabled" mcp:"true"`
|
||||
|
||||
Displayname string `help:"display name" mcp:"true"`
|
||||
}
|
||||
R(&DomainCreateOptions{}, "domain-create", "Create a new domain", func(s *mcclient.ClientSession, args *DomainCreateOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
|
||||
@@ -775,4 +775,17 @@ func init() {
|
||||
printObject(result)
|
||||
return nil
|
||||
})
|
||||
|
||||
type IdentityProviderGetPropertyAttributeNamesOptions struct {
|
||||
DRIVER string `help:"driver of idp to query" json:"driver" choices:"oidc|oauth2|saml|cas"`
|
||||
TEMPLATE string `help:"template of idp to query" json:"template"`
|
||||
}
|
||||
R(&IdentityProviderGetPropertyAttributeNamesOptions{}, "idp-attribute-names", "Get property attribute names of a idp", func(s *mcclient.ClientSession, args *IdentityProviderGetPropertyAttributeNamesOptions) error {
|
||||
result, err := modules.IdentityProviders.Get(s, "attribute-names", jsonutils.Marshal(args))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(result)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,8 +43,10 @@ func init() {
|
||||
cmd.PerformClass("clean", &identity_options.ProjectCleanOptions{})
|
||||
|
||||
type ProjectShowOptions struct {
|
||||
_ struct{} `mcp-desc:"查询项目详情。ID 可用 climc_project_list 返回的 id/name;跨域时可传 domain"`
|
||||
|
||||
ID string `help:"ID or Name of project"`
|
||||
Domain string `help:"Domain"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
}
|
||||
R(&ProjectShowOptions{}, "project-show", "Show details of project", func(s *mcclient.ClientSession, args *ProjectShowOptions) error {
|
||||
query := jsonutils.NewDict()
|
||||
@@ -62,7 +64,13 @@ func init() {
|
||||
printObject(result)
|
||||
return nil
|
||||
})
|
||||
R(&ProjectShowOptions{}, "project-delete", "Delete a project", func(s *mcclient.ClientSession, args *ProjectShowOptions) error {
|
||||
type ProjectDeleteOptions struct {
|
||||
_ struct{} `mcp-desc:"删除项目。若尚不知 id,先用 climc_project_list 定位;跨域时可传 domain"`
|
||||
|
||||
ID string `help:"ID or Name of project"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
}
|
||||
R(&ProjectDeleteOptions{}, "project-delete", "Delete a project", func(s *mcclient.ClientSession, args *ProjectDeleteOptions) error {
|
||||
query := jsonutils.NewDict()
|
||||
if len(args.Domain) > 0 {
|
||||
domainId, err := modules.Domains.GetId(s, args.Domain, nil)
|
||||
@@ -83,12 +91,14 @@ func init() {
|
||||
})
|
||||
|
||||
type ProjectCreateOptions struct {
|
||||
_ struct{} `mcp-desc:"创建项目。NAME 必填;可选 domain/displayname/desc"`
|
||||
|
||||
NAME string `help:"Name of new project"`
|
||||
Displayname string `help:"display name"`
|
||||
Domain string `help:"Domain"`
|
||||
Desc string `help:"Description"`
|
||||
Enabled bool `help:"Project is enabled"`
|
||||
Disabled bool `help:"Project is disabled"`
|
||||
Displayname string `help:"display name" mcp:"true"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
Desc string `help:"Description" mcp:"true"`
|
||||
Enabled bool `help:"Project is enabled" mcp:"true"`
|
||||
Disabled bool `help:"Project is disabled" mcp:"true"`
|
||||
}
|
||||
R(&ProjectCreateOptions{}, "project-create", "Create a project", func(s *mcclient.ClientSession, args *ProjectCreateOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
|
||||
@@ -37,11 +37,11 @@ func init() {
|
||||
RoleDomain string `help:"Domain for role"`
|
||||
Limit int64 `help:"maximal returned number of rows"`
|
||||
Offset int64 `help:"offset index of returned results"`
|
||||
Users []string `help:"fitler by user id or name"`
|
||||
Groups []string `help:"fitler by group id or name"`
|
||||
Roles []string `help:"fitler by role id or name"`
|
||||
Projects []string `help:"fitler by project id or name"`
|
||||
Domains []string `help:"fitler by domain id or name"`
|
||||
Users []string `help:"filter by user id or name"`
|
||||
Groups []string `help:"filter by group id or name"`
|
||||
Roles []string `help:"filter by role id or name"`
|
||||
Projects []string `help:"filter by project id or name"`
|
||||
Domains []string `help:"filter by domain id or name"`
|
||||
|
||||
ProjectDomainId string
|
||||
ProjectDomains []string `help:"filter by project's domain id or name"`
|
||||
|
||||
@@ -64,12 +64,14 @@ func init() {
|
||||
return nil
|
||||
})*/
|
||||
|
||||
type UserDetailOptions struct {
|
||||
type UserShowOptions struct {
|
||||
_ struct{} `mcp-desc:"查询用户详情。ID 可用 climc_user_list 返回的 id/name;跨域时可传 domain"`
|
||||
|
||||
ID string `help:"ID of user"`
|
||||
Domain string `help:"Domain"`
|
||||
System bool `help:"show system user"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
System bool `help:"show system user" mcp:"true"`
|
||||
}
|
||||
R(&UserDetailOptions{}, "user-show", "Show details of user", func(s *mcclient.ClientSession, args *UserDetailOptions) error {
|
||||
R(&UserShowOptions{}, "user-show", "Show details of user", func(s *mcclient.ClientSession, args *UserShowOptions) error {
|
||||
query := jsonutils.NewDict()
|
||||
if len(args.Domain) > 0 {
|
||||
domainId, err := modules.Domains.GetId(s, args.Domain, nil)
|
||||
@@ -89,7 +91,14 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
|
||||
R(&UserDetailOptions{}, "user-delete", "Delete user", func(s *mcclient.ClientSession, args *UserDetailOptions) error {
|
||||
type UserDeleteOptions struct {
|
||||
_ struct{} `mcp-desc:"删除用户。若尚不知 id,先用 climc_user_list 定位;跨域时可传 domain"`
|
||||
|
||||
ID string `help:"ID of user"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
System bool `help:"show system user" mcp:"true"`
|
||||
}
|
||||
R(&UserDeleteOptions{}, "user-delete", "Delete user", func(s *mcclient.ClientSession, args *UserDeleteOptions) error {
|
||||
query := jsonutils.NewDict()
|
||||
if len(args.Domain) > 0 {
|
||||
domainId, err := modules.Domains.GetId(s, args.Domain, nil)
|
||||
@@ -109,6 +118,11 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
|
||||
type UserDetailOptions struct {
|
||||
ID string `help:"ID of user"`
|
||||
Domain string `help:"Domain"`
|
||||
System bool `help:"show system user"`
|
||||
}
|
||||
R(&UserDetailOptions{}, "user-project-list", "List projects of user", func(s *mcclient.ClientSession, args *UserDetailOptions) error {
|
||||
query := jsonutils.NewDict()
|
||||
if len(args.Domain) > 0 {
|
||||
@@ -170,29 +184,31 @@ func init() {
|
||||
})
|
||||
|
||||
type UserCreateOptions struct {
|
||||
NAME string `help:"Name of the new user"`
|
||||
Domain string `help:"Domain"`
|
||||
Desc string `help:"Description"`
|
||||
Password *string `help:"Password"`
|
||||
Displayname string `help:"Displayname"`
|
||||
Email string `help:"Email"`
|
||||
Mobile string `help:"Mobile"`
|
||||
Enabled bool `help:"Enabled"`
|
||||
Disabled bool `help:"Disabled"`
|
||||
_ struct{} `mcp-desc:"创建用户。NAME 必填;建议传 password;可选 domain/email/mobile/displayname"`
|
||||
|
||||
SkipPasswordComplexityCheck bool `help:"do password complexity check, default is false"`
|
||||
NAME string `help:"Name of the new user"`
|
||||
Domain string `help:"Domain" mcp:"true"`
|
||||
Desc string `help:"Description" mcp:"true"`
|
||||
Password *string `help:"Password" mcp:"true"`
|
||||
Displayname string `help:"Displayname" mcp:"true"`
|
||||
Email string `help:"Email" mcp:"true"`
|
||||
Mobile string `help:"Mobile" mcp:"true"`
|
||||
Enabled bool `help:"Enabled" mcp:"true"`
|
||||
Disabled bool `help:"Disabled" mcp:"true"`
|
||||
|
||||
SkipPasswordComplexityCheck bool `help:"skip password complexity check" mcp:"true"`
|
||||
|
||||
// DefaultProject string `help:"Default project"`
|
||||
SystemAccount bool `help:"is a system account?"`
|
||||
NoWebConsole bool `help:"allow web console access"`
|
||||
EnableMfa bool `help:"enable TOTP mfa"`
|
||||
SystemAccount bool `help:"is a system account?" mcp:"true"`
|
||||
NoWebConsole bool `help:"disallow web console access" mcp:"true"`
|
||||
EnableMfa bool `help:"enable TOTP mfa" mcp:"true"`
|
||||
|
||||
IdpId string `help:"Id of identity provider to link with"`
|
||||
IdpEntityId string `help:"Entity id of identity provider to link with"`
|
||||
IdpId string `help:"Id of identity provider to link with" mcp:"true"`
|
||||
IdpEntityId string `help:"Entity id of identity provider to link with" mcp:"true"`
|
||||
|
||||
Lang string `help:"user default language"`
|
||||
Lang string `help:"user default language" mcp:"true"`
|
||||
|
||||
Expire string `help:"user expired at"`
|
||||
Expire string `help:"user expired at" mcp:"true"`
|
||||
}
|
||||
R(&UserCreateOptions{}, "user-create", "Create a user", func(s *mcclient.ClientSession, args *UserCreateOptions) error {
|
||||
params := jsonutils.NewDict()
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
|
||||
var (
|
||||
R = shell.R
|
||||
printList = printutils.PrintJSONList
|
||||
printObject = printutils.PrintJSONObject
|
||||
printBatchResults = printutils.PrintJSONBatchResults
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
printBatchResults = shell.PrintBatchResults
|
||||
|
||||
InvalidUpdateError = shell.InvalidUpdateError
|
||||
printObjectRecursive = printutils.PrintJSONObjectRecursive
|
||||
|
||||
@@ -56,10 +56,10 @@ type ImageOptionalOptions struct {
|
||||
OsCodename string `help:"Codename of OS"`
|
||||
OsArch string `help:"Os hardware architecture" choices:"x86|x86_64|aarch32|aarch64|riscv32|riscv64"`
|
||||
OsLang string `help:"OS Language" choices:"zh_CN|en_US"`
|
||||
Preference int64 `help:"Disk preferences"`
|
||||
Preference int64 `help:"Image disk preference weight"`
|
||||
Notes string `help:"Notes about the image"`
|
||||
Hypervisor []string `help:"Prefer hypervisor type" choices:"kvm|esxi|baremetal|container|openstack|ctyun"`
|
||||
DiskDriver string `help:"Perfer disk driver" choices:"virtio|scsi|pvscsi|ide|sata"`
|
||||
DiskDriver string `help:"Prefer disk driver" choices:"virtio|scsi|pvscsi|ide|sata"`
|
||||
NetDriver string `help:"Preferred network driver" choices:"virtio|e1000|vmxnet3"`
|
||||
DisableUsbKbd bool `help:"Disable usb keyboard on this image(for hypervisor kvm)"`
|
||||
BootMode string `help:"UEFI support" choices:"UEFI|BIOS"`
|
||||
|
||||
@@ -52,6 +52,7 @@ func initKubeCluster() {
|
||||
cmd.Perform("delete-machines", new(o.KubeClusterDeleteMachinesOptions))
|
||||
cmd.Perform("add-machines", new(o.KubeClusterAddMachinesOptions))
|
||||
cmd.PerformClass("gc", new(o.ClusterGCOpts))
|
||||
cmd.PerformClass("history-data-clean", new(o.ClusterHistoryDataCleanOpts))
|
||||
cmd.Perform("set-extra-config", new(o.ClusterSetExtraConfigOpt))
|
||||
cmd.Get("extra-config", new(o.IdentOptions))
|
||||
|
||||
@@ -311,6 +312,9 @@ func initKubeCluster() {
|
||||
|
||||
// 2. edit yaml
|
||||
yaml, err := shellutils.Edit(setting.YAMLString())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "edit component setting")
|
||||
}
|
||||
if len(yaml) == 0 {
|
||||
if !args.Force {
|
||||
log.Infof("Nothing to update")
|
||||
|
||||
@@ -101,6 +101,9 @@ func initContainerRegistry() {
|
||||
Username: args.Username,
|
||||
Password: args.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "download image by manager")
|
||||
}
|
||||
}
|
||||
output := args.Output
|
||||
if output == "" && fileName != "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
|
||||
@@ -17,4 +17,6 @@ func init() {
|
||||
cmd.Delete(new(options.LLMSkuDeleteOptions))
|
||||
cmd.Perform("public", &base_options.BasePublicOptions{})
|
||||
cmd.Perform("private", &base_options.BaseIdOptions{})
|
||||
cmd.Perform("clone", &options.LLMSkuCloneOptions{})
|
||||
cmd.Perform("schedulable-check", &options.LLMSkuSchedulableCheckOptions{})
|
||||
}
|
||||
|
||||
@@ -24,15 +24,15 @@ import (
|
||||
)
|
||||
|
||||
type BaseActionListOptions struct {
|
||||
Scope string `help:"scope" choices:"project|domain|system"`
|
||||
Scope string `help:"RBAC scope" choices:"project|domain|system"`
|
||||
Since string `help:"Show logs since specific date" metavar:"DATETIME"`
|
||||
Until string `help:"Show logs until specific date" metavar:"DATETIME"`
|
||||
Limit int64 `help:"Limit number of logs" default:"20"`
|
||||
Offset int64 `help:"Offset"`
|
||||
Offset int64 `help:"Offset for pagination"`
|
||||
Ascending bool `help:"Ascending order"`
|
||||
Descending bool `help:"Descending order"`
|
||||
Field []string `help:"field options"`
|
||||
Action []string `help:"Log action"`
|
||||
Field []string `help:"Fields to return"`
|
||||
Action []string `help:"Filter by log action"`
|
||||
Search string `help:"Filter action logs by obj_name, using 'like' syntax."`
|
||||
Admin bool `help:"admin mode"`
|
||||
Succ bool `help:"Show success action log only"`
|
||||
@@ -47,15 +47,17 @@ type BaseActionListOptions struct {
|
||||
}
|
||||
|
||||
type ActionListOptions struct {
|
||||
_ struct{} `mcp-desc:"查询操作日志(谁在何时对资源做了什么)。可按对象类型 type(如 server/disk/host)、对象 id、时间 since/until、action、user、project、succ/fail 过滤;默认 limit=20。排查创建失败、误操作、审计时优先调用"`
|
||||
|
||||
BaseActionListOptions
|
||||
Service []string `help:"service name"`
|
||||
Id string `help:"" metavar:"OBJ_ID"`
|
||||
Id string `help:"filter by object id" metavar:"OBJ_ID"`
|
||||
Type []string `help:"Type of relevant object" metavar:"OBJ_TYPE"`
|
||||
}
|
||||
|
||||
type TypeActionListOptions struct {
|
||||
BaseActionListOptions
|
||||
ID string `help:"" metavar:"OBJ_ID"`
|
||||
ID string `help:"Object ID" metavar:"OBJ_ID"`
|
||||
}
|
||||
|
||||
func doActionList(s *mcclient.ClientSession, args *ActionListOptions) error {
|
||||
|
||||
@@ -15,13 +15,11 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"yunion.io/x/pkg/util/printutils"
|
||||
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
)
|
||||
|
||||
var (
|
||||
R = shell.R
|
||||
printList = printutils.PrintJSONList
|
||||
printObject = printutils.PrintJSONObject
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
)
|
||||
|
||||
@@ -15,17 +15,15 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"yunion.io/x/pkg/util/printutils"
|
||||
|
||||
"yunion.io/x/onecloud/cmd/climc/shell"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
|
||||
)
|
||||
|
||||
var (
|
||||
R = shell.R
|
||||
printList = printutils.PrintJSONList
|
||||
printObject = printutils.PrintJSONObject
|
||||
printBatchResults = printutils.PrintJSONBatchResults
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
printBatchResults = shell.PrintBatchResults
|
||||
)
|
||||
|
||||
func NewResourceCmd(manager modulebase.IBaseManager) *shell.ResourceCmd {
|
||||
|
||||
@@ -126,7 +126,7 @@ func printQuotaList(result jsonutils.JSONObject) {
|
||||
|
||||
func init() {
|
||||
type QuotaOptions struct {
|
||||
Scope string `help:"scope" choices:"domain|project"`
|
||||
Scope string `help:"RBAC scope" choices:"domain|project"`
|
||||
Project string `help:"Tenant name or ID" json:"tenant"`
|
||||
Domain string `help:"Domain name or ID" json:"domain"`
|
||||
Refresh bool `help:"refresh" json:"refresh,omitfalse"`
|
||||
@@ -413,7 +413,7 @@ func init() {
|
||||
})
|
||||
|
||||
type CleanPendingUsageOptions struct {
|
||||
Scope string `help:"scope" choices:"domain|project"`
|
||||
Scope string `help:"RBAC scope" choices:"domain|project"`
|
||||
Project string `help:"Tenant name or ID" json:"tenant"`
|
||||
Domain string `help:"Domain name or ID" json:"domain"`
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ func init() {
|
||||
}
|
||||
R(&ScheduledTaskListOptions{}, "scheduledtask-list", "list Scheduled Task", func(s *mcclient.ClientSession, args *ScheduledTaskListOptions) error {
|
||||
params, err := options.ListStructToParams(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks, err := modules.ScheduledTask.List(s, params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
|
||||
var (
|
||||
R = shell.R
|
||||
printList = printutils.PrintJSONList
|
||||
printObject = printutils.PrintJSONObject
|
||||
printBatchResults = printutils.PrintJSONBatchResults
|
||||
printList = shell.PrintList
|
||||
printObject = shell.PrintObject
|
||||
printBatchResults = shell.PrintBatchResults
|
||||
|
||||
InvalidUpdateError = shell.InvalidUpdateError
|
||||
printObjectRecursive = printutils.PrintJSONObjectRecursive
|
||||
|
||||
@@ -15,10 +15,15 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/pkg/util/printutils"
|
||||
@@ -37,45 +42,111 @@ const (
|
||||
|
||||
var outputFormat = OUTPUT_FORMAT_TABLE
|
||||
|
||||
// goroutine 本地输出:MCP 并发 tools/call 时避免劫持全局 os.Stdout。
|
||||
type outputState struct {
|
||||
writer io.Writer
|
||||
format string
|
||||
}
|
||||
|
||||
var outputStates sync.Map // uint64(goid) -> *outputState
|
||||
|
||||
func OutputFormat(s string) {
|
||||
outputFormat = s
|
||||
}
|
||||
|
||||
// PushOutput 将当前 goroutine 的 shell 输出重定向到 w,并可选覆盖格式。
|
||||
// 返回的 restore 必须在同一 goroutine 调用。
|
||||
func PushOutput(w io.Writer, format string) (restore func()) {
|
||||
id := goroutineID()
|
||||
prev, _ := outputStates.Load(id)
|
||||
outputStates.Store(id, &outputState{writer: w, format: format})
|
||||
return func() {
|
||||
if prev != nil {
|
||||
outputStates.Store(id, prev)
|
||||
} else {
|
||||
outputStates.Delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func currentWriter() io.Writer {
|
||||
if v, ok := outputStates.Load(goroutineID()); ok {
|
||||
if s := v.(*outputState); s != nil && s.writer != nil {
|
||||
return s.writer
|
||||
}
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func currentFormat() string {
|
||||
if v, ok := outputStates.Load(goroutineID()); ok {
|
||||
if s := v.(*outputState); s != nil && s.format != "" {
|
||||
return s.format
|
||||
}
|
||||
}
|
||||
return outputFormat
|
||||
}
|
||||
|
||||
func goroutineID() uint64 {
|
||||
b := make([]byte, 64)
|
||||
b = b[:runtime.Stack(b, false)]
|
||||
b = bytes.TrimPrefix(b, []byte("goroutine "))
|
||||
i := bytes.IndexByte(b, ' ')
|
||||
if i <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseUint(string(b[:i]), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func PrintList(list *printutils.ListResult, columns []string) {
|
||||
switch outputFormat {
|
||||
w := currentWriter()
|
||||
switch currentFormat() {
|
||||
case OUTPUT_FORMAT_TABLE:
|
||||
printutils.PrintJSONList(list, columns)
|
||||
if w == os.Stdout {
|
||||
printutils.PrintJSONList(list, columns)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, jsonutils.Marshal(list).PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
case OUTPUT_FORMAT_JSON:
|
||||
fmt.Print(jsonutils.Marshal(list).PrettyString())
|
||||
fmt.Print("\n")
|
||||
fmt.Fprint(w, jsonutils.Marshal(list).PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
case OUTPUT_FORMAT_YAML:
|
||||
fmt.Print(jsonutils.Marshal(list).YAMLString())
|
||||
fmt.Fprint(w, jsonutils.Marshal(list).YAMLString())
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown output format: %q\n", outputFormat)
|
||||
fmt.Fprintf(os.Stderr, "unknown output format: %q\n", currentFormat())
|
||||
}
|
||||
}
|
||||
|
||||
func PrintObject(obj jsonutils.JSONObject) {
|
||||
switch outputFormat {
|
||||
w := currentWriter()
|
||||
switch currentFormat() {
|
||||
case OUTPUT_FORMAT_TABLE:
|
||||
printutils.PrintJSONObject(obj)
|
||||
if w == os.Stdout {
|
||||
printutils.PrintJSONObject(obj)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, obj.PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
case OUTPUT_FORMAT_KV:
|
||||
printObjectFmtKv(obj)
|
||||
case OUTPUT_FORMAT_JSON:
|
||||
fmt.Print(obj.PrettyString())
|
||||
fmt.Print("\n")
|
||||
fmt.Fprint(w, obj.PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
case OUTPUT_FORMAT_YAML:
|
||||
fmt.Print(obj.YAMLString())
|
||||
fmt.Fprint(w, obj.YAMLString())
|
||||
case OUTPUT_FORMAT_FLATTEN_TABLE:
|
||||
printObjectRecursive(obj)
|
||||
case OUTPUT_FORMAT_FLATTEN_KV:
|
||||
printObjectRecursiveEx(obj, printObjectFmtKv)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown output format: %q\n", outputFormat)
|
||||
fmt.Fprintf(os.Stderr, "unknown output format: %q\n", currentFormat())
|
||||
}
|
||||
}
|
||||
|
||||
func printObjectFmtKv(obj jsonutils.JSONObject) {
|
||||
w := currentWriter()
|
||||
m, _ := obj.GetMap()
|
||||
maxWidth := 0
|
||||
keys := make([]string, 0, len(m))
|
||||
@@ -95,7 +166,7 @@ func printObjectFmtKv(obj jsonutils.JSONObject) {
|
||||
} else {
|
||||
s = objV.String()
|
||||
}
|
||||
fmt.Printf("%*s: %s\n", maxWidth, k, s)
|
||||
fmt.Fprintf(w, "%*s: %s\n", maxWidth, k, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +178,24 @@ func printObjectRecursiveEx(obj jsonutils.JSONObject, cb printutils.PrintJSONObj
|
||||
printutils.PrintJSONObjectRecursiveEx(obj, cb)
|
||||
}
|
||||
|
||||
func PrintBatchResults(results []printutils.SubmitResult, columns []string) {
|
||||
w := currentWriter()
|
||||
switch currentFormat() {
|
||||
case OUTPUT_FORMAT_JSON:
|
||||
fmt.Fprint(w, jsonutils.Marshal(results).PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
default:
|
||||
if w == os.Stdout {
|
||||
printutils.PrintJSONBatchResults(results, columns)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, jsonutils.Marshal(results).PrettyString())
|
||||
fmt.Fprint(w, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func printBatchResults(results []printutils.SubmitResult, columns []string) {
|
||||
printutils.PrintJSONBatchResults(results, columns)
|
||||
PrintBatchResults(results, columns)
|
||||
}
|
||||
|
||||
func ExportList(list *printutils.ListResult, file string, exportKeys string, exportTexts string, columns []string) {
|
||||
|
||||
@@ -32,10 +32,10 @@ import (
|
||||
|
||||
func init() {
|
||||
type ParametersListOptions struct {
|
||||
Name string `help:"List parameter of specificated name"`
|
||||
NamespaceId string `help:"List parameter of specificated namespace id, ADMIN only"`
|
||||
User string `help:"List parameter of specificated user id, ADMIN only" token:"user-id"`
|
||||
Service string `help:"List parameter of specificated service id, ADMIN only"`
|
||||
Name string `help:"List parameter of specified name"`
|
||||
NamespaceId string `help:"List parameter of specified namespace id, ADMIN only"`
|
||||
User string `help:"List parameter of specified user id, ADMIN only" token:"user-id"`
|
||||
Service string `help:"List parameter of specified service id, ADMIN only"`
|
||||
options.BaseListOptions
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ func init() {
|
||||
})
|
||||
|
||||
type ParametersShowOptions struct {
|
||||
NamespaceId string `help:"Show parameter of specificated namespace id, ADMIN only"`
|
||||
User string `help:"Show parameter of specificated user id, ADMIN only"`
|
||||
Service string `help:"Show parameter of specificated service id, ADMIN only"`
|
||||
NamespaceId string `help:"Show parameter of specified namespace id, ADMIN only"`
|
||||
User string `help:"Show parameter of specified user id, ADMIN only"`
|
||||
Service string `help:"Show parameter of specified service id, ADMIN only"`
|
||||
NAME string `help:"The name of parameter"`
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ func init() {
|
||||
})
|
||||
|
||||
type ParametersCreateOptions struct {
|
||||
User string `help:"Create parameter for specificated user id, ADMIN only"`
|
||||
Service string `help:"Create parameter for specificated service id, ADMIN only"`
|
||||
User string `help:"Create parameter for specified user id, ADMIN only"`
|
||||
Service string `help:"Create parameter for specified service id, ADMIN only"`
|
||||
NAME string `help:"The name of parameter"`
|
||||
VALUE string `help:"The content of parameter"`
|
||||
}
|
||||
@@ -140,9 +140,9 @@ func init() {
|
||||
})
|
||||
|
||||
type ParametersEditOptions struct {
|
||||
NamespaceId string `help:"List parameter of specificated namespace id, ADMIN only"`
|
||||
User string `help:"Update parameter of specificated user id, ADMIN only"`
|
||||
Service string `help:"Update parameter of specificated service id, ADMIN only"`
|
||||
NamespaceId string `help:"List parameter of specified namespace id, ADMIN only"`
|
||||
User string `help:"Update parameter of specified user id, ADMIN only"`
|
||||
Service string `help:"Update parameter of specified service id, ADMIN only"`
|
||||
NAME string `help:"The name of parameter"`
|
||||
}
|
||||
|
||||
@@ -222,8 +222,8 @@ func init() {
|
||||
})
|
||||
|
||||
type ParametersDeleteOptions struct {
|
||||
User string `help:"Delete parameter of specificated user id, ADMIN only"`
|
||||
Service string `help:"Delete parameter of specificated service id, ADMIN only"`
|
||||
User string `help:"Delete parameter of specified user id, ADMIN only"`
|
||||
Service string `help:"Delete parameter of specified service id, ADMIN only"`
|
||||
NAME string `help:"The name of parameter"`
|
||||
}
|
||||
|
||||
@@ -248,8 +248,8 @@ func init() {
|
||||
})
|
||||
|
||||
type ParameterCloneOptions struct {
|
||||
User string `help:"Clone parameter of specificated user id"`
|
||||
Service string `help:"Clone parameter of specificated service id"`
|
||||
User string `help:"Clone parameter of specified user id"`
|
||||
Service string `help:"Clone parameter of specified service id"`
|
||||
NAME string `help:"The name of parameter"`
|
||||
DestUser string `help:"destination user id of clone action"`
|
||||
DestService string `help:"destination service id of clone action"`
|
||||
|
||||
@@ -232,8 +232,11 @@ func (fs *FetcherFs) doFetchData(idx int64) error {
|
||||
}
|
||||
|
||||
buf, err := ioutil.ReadAll(lz4Reader)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read lz4 data")
|
||||
}
|
||||
if len(buf) != int(end-start+1) {
|
||||
return errors.Wrap(err, "written local file")
|
||||
return errors.Errorf("written local file: size mismatch got %d want %d", len(buf), int(end-start+1))
|
||||
}
|
||||
|
||||
written, err := fs.localFile.WriteAt(buf, start)
|
||||
|
||||
@@ -81,7 +81,7 @@ func newExecutor(options *BaseOptions) (ipmitool.IPMIExecutor, error) {
|
||||
if options.Port > 0 {
|
||||
port = options.Port
|
||||
}
|
||||
return ipmitool.NewLanPlusIPMIWithPort(options.HOST, options.User, options.PASSWD, port), nil
|
||||
return ipmitool.NewLanPlusIPMIWithPort(options.HOST, options.User, options.PASSWD, port)
|
||||
}
|
||||
return nil, fmt.Errorf("Unsupported mode: %s", options.MODE)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"yunion.io/x/pkg/util/signalutils"
|
||||
"yunion.io/x/pkg/utils"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/compute"
|
||||
"yunion.io/x/onecloud/pkg/hostman/isolated_device"
|
||||
"yunion.io/x/onecloud/pkg/hostman/isolated_device/container_device"
|
||||
"yunion.io/x/onecloud/pkg/hostman/options"
|
||||
@@ -67,7 +68,7 @@ func NewDaemon() (*Daemon, error) {
|
||||
}
|
||||
for i := range devCfg.Devices {
|
||||
if devCfg.Devices[i].Type == isolated_device.ContainerDeviceTypeNvidiaMps {
|
||||
dev, err := container_device.NewPCIGPURenderBaseDevice(devCfg.Devices[i].Path, 0, isolated_device.ContainerDeviceTypeNvidiaMps)
|
||||
dev, err := container_device.NewPCIGPURenderBaseDevice(devCfg.Devices[i].Path, 0, api.GPU_TYPE, api.DEVICE_SHARING_MODE_MPS)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "parse pci device %s", devCfg.Devices[i].Path)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,24 @@
|
||||
# MCP Server
|
||||
|
||||
MCP Server 是 Cloudpods 多云管理平台的核心组件之一,负责处理多云资源的统一管理和调度。
|
||||
Cloudpods MCP Server:通过 MCP 协议把 climc 能力暴露给 AI 客户端(Cursor / Claude 等)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
├── adapters/ # 适配器模块,用于对接不同云平台的API
|
||||
├── config/ # 配置模块,处理服务配置和加载
|
||||
├── models/ # 数据模型,定义云资源的数据结构
|
||||
├── registry/ # 注册中心,管理可用的工具和服务
|
||||
├── server/ # 服务核心,包含服务启动和初始化逻辑
|
||||
└── tools/ # 工具模块,实现各种云资源管理功能
|
||||
├── adapters/ # Cloudpods 认证与 ClientSession
|
||||
├── climcgen/ # 从 climc CommandTable + Options tag 生成 MCP tools
|
||||
├── options/ # 服务配置
|
||||
├── registry/ # MCP tool 注册
|
||||
├── server/ # SSE / stdio 服务
|
||||
└── service/ # 进程入口装配
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
|
||||
MCP Server 采用模块化设计,主要包括以下几个核心模块:
|
||||
|
||||
1. **适配器模块 (Adapters)**: 负责与不同云平台的API进行交互,实现资源的统一管理。
|
||||
2. **配置模块 (Config)**: 处理服务的配置加载和管理,支持多种配置方式。
|
||||
3. **数据模型 (Models)**: 定义云资源的数据结构,为其他模块提供统一的数据访问接口。
|
||||
4. **注册中心 (Registry)**: 管理可用的工具和服务,支持动态注册和发现。
|
||||
5. **服务核心 (Server)**: 负责服务的启动、初始化和生命周期管理。
|
||||
6. **工具模块 (Tools)**: 实现各种云资源管理功能,如VPC、网络、镜像等。
|
||||
|
||||
## 运行机制
|
||||
|
||||
1. 服务启动时,首先加载配置文件并初始化各个模块。
|
||||
2. 适配器模块根据配置连接到相应的云平台。
|
||||
3. 注册中心注册所有可用的工具和服务。
|
||||
4. 服务核心启动HTTP服务器,监听客户端请求。
|
||||
5. 客户端通过API调用相应的工具来管理云资源。
|
||||
1. 启动时 blank-import climc shell 包,填充 `shell.CommandTable`
|
||||
2. 扫描 Options 上带 `mcp-desc` 的命令,用 Options struct tag 生成 schema 并注册 tools
|
||||
3. 工具调用时用 AK/SK(或 Header)建 session,执行对应 climc callback,JSON 输出返回给客户端
|
||||
|
||||
## 主要功能
|
||||
## 扩展工具
|
||||
|
||||
- 统一管理多云资源(VPC、网络、镜像、主机等)
|
||||
- 支持多种云平台(AWS、Azure、阿里云等)
|
||||
- 提供RESTful API接口
|
||||
- 支持资源的查询、创建、更新和删除操作
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置文件位于 `options/options.go`,主要包含以下配置项:
|
||||
|
||||
- ServerConfig: 服务配置,如监听地址、端口等
|
||||
- MCPConfig: MCP相关配置
|
||||
- ExternalConfig: 外部服务配置
|
||||
|
||||
## 开发指南
|
||||
|
||||
1. 实现新的云资源管理功能时,需要在 `tools/` 目录下创建相应的工具文件。
|
||||
2. 工具需要实现 `Tool` 接口,包括 `GetTool`、`Handle` 和 `GetName` 方法。
|
||||
3. 数据模型定义在 `models/` 目录下,需要根据云平台API文档进行定义。
|
||||
4. 适配器实现在 `adapters/` 目录下,用于与云平台API进行交互。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request来改进MCP Server。
|
||||
在对应 climc Options 上增加 `_ struct{} \`mcp-desc:"..."\``(并按需给字段加 `mcp:"true"`),重启 mcp-server 即可注册。
|
||||
|
||||
@@ -25,6 +25,12 @@ definitions:
|
||||
description: 权限Id
|
||||
type: string
|
||||
x-go-name: CloudpolicyId
|
||||
cloudpolicy_ids:
|
||||
description: 权限Id列表
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-go-name: CloudpolicyIds
|
||||
type: object
|
||||
x-go-package: yunion.io/x/onecloud/pkg/apis/cloudid
|
||||
CloudgroupCreateInput:
|
||||
@@ -108,6 +114,12 @@ definitions:
|
||||
description: 权限Id
|
||||
type: string
|
||||
x-go-name: CloudpolicyId
|
||||
cloudpolicy_ids:
|
||||
description: 权限Id列表
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-go-name: CloudpolicyIds
|
||||
type: object
|
||||
x-go-package: yunion.io/x/onecloud/pkg/apis/cloudid
|
||||
CloudgroupDetails:
|
||||
|
||||
19
go.mod
19
go.mod
@@ -6,9 +6,12 @@ require (
|
||||
bazil.org/fuse v0.0.0-20180421153158-65cc252bf669
|
||||
github.com/360EntSecGroup-Skylar/excelize v1.4.0
|
||||
github.com/LeeEirc/terminalparser v0.0.0-20240205084113-fbf78c8480f2
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.684
|
||||
github.com/anacrolix/torrent v1.57.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/benbjohnson/clock v1.0.0
|
||||
github.com/bitly/go-simplejson v0.5.0
|
||||
github.com/c-bata/go-prompt v0.2.4
|
||||
@@ -90,6 +93,7 @@ require (
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/sys v0.35.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/text v0.28.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
|
||||
@@ -108,14 +112,14 @@ require (
|
||||
k8s.io/cri-api v0.28.15
|
||||
k8s.io/klog/v2 v2.90.1
|
||||
moul.io/http2curl/v2 v2.3.0
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260616113329-0b3b4fbed80a
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260831080823-1631719623c8
|
||||
yunion.io/x/executor v0.0.0-20260312022053-f538abd2b005
|
||||
yunion.io/x/jsonutils v1.0.1-0.20260715075349-615cfb44ff7c
|
||||
yunion.io/x/log v1.0.1-0.20240305175729-7cf2d6cd5a91
|
||||
yunion.io/x/ovsdb v0.0.0-20230306173834-f164f413a900
|
||||
yunion.io/x/pkg v1.10.4-0.20260422030155-01b100134978
|
||||
yunion.io/x/pkg v1.10.4-0.20260722094907-0da70a14584f
|
||||
yunion.io/x/s3cli v0.0.0-20241221171442-1c11599d28e1
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20251231025938-b0a38f6e9fab
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20260807102031-1d7e260d0dcc
|
||||
yunion.io/x/structarg v0.0.0-20231017124457-df4d5009457c
|
||||
)
|
||||
|
||||
@@ -144,6 +148,7 @@ require (
|
||||
github.com/DataDog/datadog-go/v5 v5.0.2 // indirect
|
||||
github.com/DataDog/go-tuf v0.3.0--fix-localmeta-fork // indirect
|
||||
github.com/DataDog/sketches-go v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Microsoft/hcsshim v0.11.4 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
@@ -169,10 +174,8 @@ require (
|
||||
github.com/aokoli/goutils v1.0.1 // indirect
|
||||
github.com/apparentlymart/go-cidr v1.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go v1.44.194 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
@@ -182,12 +185,9 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1-0.20260401181752-9074b3ddace9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/basgys/goxml2json v1.1.1-0.20181031222924-996d9fc8d313 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
@@ -403,7 +403,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.17.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
google.golang.org/api v0.167.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@@ -230,8 +230,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 h1:wuZ5uW2uhJR63zwNlqWH2W4aL4ZjeJP3o92/W+odDY4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9/go.mod h1:/G58M2fGszCrOzvJUkDdY8O9kycodunH4VdT5oBAqls=
|
||||
github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1-0.20260401181752-9074b3ddace9 h1:qi11riT4zVjV41Nh74zbBgK0EQbkbZG8rPm4shDFlJk=
|
||||
github.com/aws/aws-sdk-go-v2/service/organizations v1.51.1-0.20260401181752-9074b3ddace9/go.mod h1:urLFj1twuR/h5T0wN/2/kmY1gxBFa1tTKr+c60lZ2fA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 h1:mUI3b885qJgfqKDUSj6RgbRqLdX0wGmg8ruM03zNfQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4/go.mod h1:6v8ukAxc7z4x4oBjGUsLnH7KGLY9Uhcgij19UJNkiMg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
|
||||
@@ -1783,13 +1781,11 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260616113329-0b3b4fbed80a h1:6R6t1tPpU/aR61wj5zCSC95KMglfM1ES85nbTneZSrk=
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260616113329-0b3b4fbed80a/go.mod h1:rCz5Nds6Zi/5uaj51oRDYdj5fZ5uULmKbuBAgNLuI+w=
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260831080823-1631719623c8 h1:IiaYBgXzTG9rxMRZbzW2uWdGEYs82eSnFcTku6tFjWw=
|
||||
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260831080823-1631719623c8/go.mod h1:FLNUcF+R7ep5AmH7cVHRw1xFfxEKahDzyDRWcV3naOc=
|
||||
yunion.io/x/executor v0.0.0-20260312022053-f538abd2b005 h1:3sWwcjGXGjG9mLBWa7AyLq+QSi0udTAx21pfVQRFMBE=
|
||||
yunion.io/x/executor v0.0.0-20260312022053-f538abd2b005/go.mod h1:Uxuou9WQIeJXNpy7t2fPLL0BYLvLiMvGQwY7Qc6aSws=
|
||||
yunion.io/x/jsonutils v0.0.0-20190625054549-a964e1e8a051/go.mod h1:4N0/RVzsYL3kH3WE/H1BjUQdFiWu50JGCFQuuy+Z634=
|
||||
yunion.io/x/jsonutils v1.0.1-0.20250507052344-1abcf4f443b1 h1:/+THlvf/MvgCW+7KeCDCr33e81KSRa5JmdZ1IIyLOXQ=
|
||||
yunion.io/x/jsonutils v1.0.1-0.20250507052344-1abcf4f443b1/go.mod h1:VK4Z93dgiKgAijcSqbMKmGaBMJuHulR16Hz4K015ZPo=
|
||||
yunion.io/x/jsonutils v1.0.1-0.20260715075349-615cfb44ff7c h1:FthZM1upg4/yF4vmYheo/J7a738sGPjWPKk5kY88sH0=
|
||||
yunion.io/x/jsonutils v1.0.1-0.20260715075349-615cfb44ff7c/go.mod h1:VK4Z93dgiKgAijcSqbMKmGaBMJuHulR16Hz4K015ZPo=
|
||||
yunion.io/x/log v0.0.0-20190514041436-04ce53b17c6b/go.mod h1:+gauLs73omeJAPlsXcevLsJLKixV+sR/E7WSYTSx1fE=
|
||||
@@ -1800,12 +1796,12 @@ yunion.io/x/ovsdb v0.0.0-20230306173834-f164f413a900 h1:Hu/4ERvoWaN6aiFs4h4/yvVB
|
||||
yunion.io/x/ovsdb v0.0.0-20230306173834-f164f413a900/go.mod h1:0vLkNEhlmA64HViPBAnSTUMrx5QP1CLsxXmxDKQ80tc=
|
||||
yunion.io/x/pkg v0.0.0-20190620104149-945c25821dbf/go.mod h1:t6rEGG2sQ4J7DhFxSZVOTjNd0YO/KlfWQyK1W4tog+E=
|
||||
yunion.io/x/pkg v0.0.0-20190628082551-f4033ba2ea30/go.mod h1:t6rEGG2sQ4J7DhFxSZVOTjNd0YO/KlfWQyK1W4tog+E=
|
||||
yunion.io/x/pkg v1.10.4-0.20260422030155-01b100134978 h1:+BS9DValwi0xHa92xrB+RR6Cxp7bDbXVysa/KiC18rY=
|
||||
yunion.io/x/pkg v1.10.4-0.20260422030155-01b100134978/go.mod h1:0Bwxqd9MA3ACi119/l02FprY/o9gHahmYC2bsSbnVpM=
|
||||
yunion.io/x/pkg v1.10.4-0.20260722094907-0da70a14584f h1:eRWHOwgnEK4dMoq7TQ61qZxly3LF0s7tH6nlcahvZiA=
|
||||
yunion.io/x/pkg v1.10.4-0.20260722094907-0da70a14584f/go.mod h1:0Bwxqd9MA3ACi119/l02FprY/o9gHahmYC2bsSbnVpM=
|
||||
yunion.io/x/s3cli v0.0.0-20241221171442-1c11599d28e1 h1:1KJ3YYinydPHpDEQRXdr/T8SYcKZ5Er+m489H+PnaQ4=
|
||||
yunion.io/x/s3cli v0.0.0-20241221171442-1c11599d28e1/go.mod h1:0iFKpOs1y4lbCxeOmq3Xx/0AcQoewVPwj62eRluioEo=
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20251231025938-b0a38f6e9fab h1:5m/bSzW3uTAk83rp9eethbYsxJFYInFVeU1RDkteW4E=
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20251231025938-b0a38f6e9fab/go.mod h1:vCIZpqhZ5Jzaq3tFyrti/vv8BijQKtkzSgNT/uH4H5A=
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20260807102031-1d7e260d0dcc h1:di0CJ1Fu/aeY0Lfvr52sQx8VTOm0JyZXJz0aC+7p6No=
|
||||
yunion.io/x/sqlchemy v1.1.3-0.20260807102031-1d7e260d0dcc/go.mod h1:vCIZpqhZ5Jzaq3tFyrti/vv8BijQKtkzSgNT/uH4H5A=
|
||||
yunion.io/x/structarg v0.0.0-20231017124457-df4d5009457c h1:QuLab2kSRECZRxo4Lo2KcYn6XjQFDGaZ1+x0pYDVVwQ=
|
||||
yunion.io/x/structarg v0.0.0-20231017124457-df4d5009457c/go.mod h1:EP6NSv2C0zzqBDTKumv8hPWLb3XvgMZDHQRfyuOrQng=
|
||||
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
|
||||
|
||||
@@ -1609,6 +1609,7 @@ var messageKeyToIndex = map[string]int{
|
||||
"loadbalancer is already associated with eip": 780,
|
||||
"loadbalancer is locked, cannot delete": 1366,
|
||||
"local host storage is not empty": 1177,
|
||||
"local_path SKU with HAMi requires per-GPU VRAM: set devices[].memory_mb on the LLM SKU": 2131,
|
||||
"locally stored disks do not support detaching": 394,
|
||||
"locally stored system disks do not support changing configuration": 391,
|
||||
"login_account exceeds 32 characters": 820,
|
||||
@@ -2172,7 +2173,7 @@ var messageKeyToIndex = map[string]int{
|
||||
"zone_ids must be in the same cloudregion": 1582,
|
||||
}
|
||||
|
||||
var en_USIndex = []uint32{ // 2132 elements
|
||||
var en_USIndex = []uint32{ // 2133 elements
|
||||
// Entry 0 - 1F
|
||||
0x00000000, 0x0000001b, 0x00000034, 0x00000049,
|
||||
0x00000067, 0x0000007a, 0x00000099, 0x000000d4,
|
||||
@@ -2773,9 +2774,10 @@ var en_USIndex = []uint32{ // 2132 elements
|
||||
0x00011c60, 0x00011c78, 0x00011c8e, 0x00011c99,
|
||||
0x00011ca7, 0x00011cba, 0x00011ccf, 0x00011ce4,
|
||||
0x00011cf3, 0x00011d12, 0x00011d2c, 0x00011d41,
|
||||
} // Size: 8552 bytes
|
||||
0x00011d98,
|
||||
} // Size: 8532 bytes
|
||||
|
||||
const en_USData string = "" + // Size: 73025 bytes
|
||||
const en_USData string = "" + // Size: 73112 bytes
|
||||
"\x02not found tenantId in body\x02failed to change project\x02get passwo" +
|
||||
"rd in body\x02username or password is empty\x02missing credential\x02inc" +
|
||||
"orrect username or password\x02The user has been locked, please contact " +
|
||||
@@ -3881,9 +3883,10 @@ const en_USData string = "" + // Size: 73025 bytes
|
||||
"vn controller configuration\x02unmarshal input fail %s\x02invalid charac" +
|
||||
"ters %s\x02bad config\x02network error\x02ssh protocol error\x02invalid " +
|
||||
"vpc provider\x02invalid ovn database\x02user not found\x02check name dup" +
|
||||
"lication fail %s\x02getReferenceCount fail %s\x02policy is referenced"
|
||||
"lication fail %s\x02getReferenceCount fail %s\x02policy is referenced" +
|
||||
"\x02local_path SKU with HAMi requires per-GPU VRAM: set devices[].memory_mb on the LLM SKU"
|
||||
|
||||
var zh_CNIndex = []uint32{ // 2132 elements
|
||||
var zh_CNIndex = []uint32{ // 2133 elements
|
||||
// Entry 0 - 1F
|
||||
0x00000000, 0x0000001b, 0x0000002e, 0x00000049,
|
||||
0x00000062, 0x00000075, 0x00000094, 0x000000bf,
|
||||
@@ -4484,9 +4487,10 @@ var zh_CNIndex = []uint32{ // 2132 elements
|
||||
0x00010c9c, 0x00010cb1, 0x00010cc7, 0x00010cd7,
|
||||
0x00010ce4, 0x00010cf4, 0x00010d0a, 0x00010d20,
|
||||
0x00010d30, 0x00010d4e, 0x00010d71, 0x00010d8a,
|
||||
} // Size: 8552 bytes
|
||||
0x00010e12,
|
||||
} // Size: 8532 bytes
|
||||
|
||||
const zh_CNData string = "" + // Size: 69002 bytes
|
||||
const zh_CNData string = "" + // Size: 69138 bytes
|
||||
"\x02获取tenantId参数失败\x02切换项目失败\x02获取password参数失败\x02用户名或密码为空\x02缺少认证信息\x02" +
|
||||
"用户名或者密码不正确\x02用户已经被锁定,请联系管理员\x02用户已经被禁用,请联系管理员\x02用户已经被禁用或处于不可用状态\x02无" +
|
||||
"效的认证信息\x02获取请求的JSON内容失败:%v\x02此用户禁止从Web控制台登录\x02请求内容为空\x02缺少id参数\x02fe" +
|
||||
@@ -5070,6 +5074,7 @@ const zh_CNData string = "" + // Size: 69002 bytes
|
||||
"告警条件类型: %s\x02解析URL %q出错:%v\x02无效的IP\x02停止dap搜索\x02ovn controller\x02o" +
|
||||
"vn controller配置\x02解析input失败 %s\x02不支持的字符 %s\x02无效的配置\x02网络错误\x02ssh协议错误" +
|
||||
"\x02无效的vpc provider\x02无效的ovn database\x02找不到用户\x02检查重复名称失败:%s\x02getRef" +
|
||||
"erenceCount调用出错:%s\x02权限定义正被使用"
|
||||
"erenceCount调用出错:%s\x02权限定义正被使用" +
|
||||
"\x02主机本地模型(local_path)推理模板使用 HAMi 时必须设置每卡显存:请到推理模板中填写 GPU 显存(MiB)"
|
||||
|
||||
// Total table size 159131 bytes (155KiB); checksum: A1EC1008
|
||||
|
||||
@@ -9951,6 +9951,11 @@
|
||||
"message": "auto_gpu_memory_utilization requires GPU devices: configure GPU on the LLM SKU",
|
||||
"translation": "开启 auto_gpu_memory_utilization 需要先在推理模板中配置 GPU 设备"
|
||||
},
|
||||
{
|
||||
"id": "local_path SKU with HAMi requires per-GPU VRAM: set devices[].memory_mb on the LLM SKU",
|
||||
"message": "local_path SKU with HAMi requires per-GPU VRAM: set devices[].memory_mb on the LLM SKU",
|
||||
"translation": "主机本地模型(local_path)推理模板使用 HAMi 时必须设置每卡显存:请到推理模板中填写 GPU 显存(MiB)"
|
||||
},
|
||||
{
|
||||
"id": "LLM is not running",
|
||||
"message": "LLM is not running",
|
||||
|
||||
@@ -16,13 +16,13 @@ package chatlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -30,6 +30,9 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"yunion.io/x/log"
|
||||
)
|
||||
|
||||
@@ -38,6 +41,8 @@ const (
|
||||
fileMinuteLayout = "20060102-1504"
|
||||
)
|
||||
|
||||
var errObjectNotFound = errors.New("object not found")
|
||||
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
LocalDir string
|
||||
@@ -67,6 +72,7 @@ type Record struct {
|
||||
ModelRequested string `json:"model_requested,omitempty"`
|
||||
ModelFinal string `json:"model_final,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
AiProviderId string `json:"provider_id,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
@@ -109,27 +115,41 @@ type ReadResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type objectStore interface {
|
||||
Get(ctx context.Context, key string) ([]byte, error)
|
||||
Put(ctx context.Context, key string, body []byte) error
|
||||
}
|
||||
|
||||
type flushItem struct {
|
||||
key string
|
||||
body []byte
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
opts Options
|
||||
mu sync.Mutex
|
||||
opts Options
|
||||
store objectStore
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
key string
|
||||
dirty bool
|
||||
loaded bool
|
||||
flushing bool
|
||||
pending []flushItem
|
||||
flushErr error
|
||||
}
|
||||
|
||||
var defaultWriter = NewWriter(Options{})
|
||||
|
||||
func NewWriter(opts Options) *Writer {
|
||||
if opts.LocalDir == "" {
|
||||
opts.LocalDir = "/var/log/yunion/aiproxy/chat"
|
||||
}
|
||||
if opts.UploadIntervalSeconds <= 0 {
|
||||
opts.UploadIntervalSeconds = 300
|
||||
}
|
||||
if opts.SegmentMinutes <= 0 || opts.SegmentMinutes > 60 {
|
||||
opts.SegmentMinutes = 60
|
||||
}
|
||||
if opts.Instance == "" {
|
||||
opts.Instance, _ = os.Hostname()
|
||||
}
|
||||
return &Writer{opts: opts}
|
||||
return &Writer{opts: opts, now: time.Now}
|
||||
}
|
||||
|
||||
func Configure(opts Options) {
|
||||
@@ -140,6 +160,10 @@ func Write(rec *Record) {
|
||||
defaultWriter.Write(rec)
|
||||
}
|
||||
|
||||
func Flush() error {
|
||||
return defaultWriter.Flush(context.Background())
|
||||
}
|
||||
|
||||
func segmentStart(ts time.Time, minutes int) time.Time {
|
||||
if minutes <= 0 || minutes > 60 {
|
||||
minutes = 60
|
||||
@@ -155,33 +179,185 @@ func logFileName(ts time.Time, minutes int) string {
|
||||
return "chat-" + start.Format(fileMinuteLayout) + ".jsonl"
|
||||
}
|
||||
|
||||
func (w *Writer) objectKey(ts time.Time) string {
|
||||
start := segmentStart(ts, w.opts.SegmentMinutes)
|
||||
return UploadKey(w.opts.S3Prefix, start, logFileName(ts, w.opts.SegmentMinutes), w.opts.Instance)
|
||||
}
|
||||
|
||||
func (w *Writer) Write(rec *Record) {
|
||||
if w == nil || rec == nil || !w.opts.Enabled {
|
||||
return
|
||||
}
|
||||
if rec.Timestamp.IsZero() {
|
||||
rec.Timestamp = time.Now()
|
||||
rec.Timestamp = w.now()
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
log.Errorf("marshal aiproxy chat log: %v", err)
|
||||
return
|
||||
}
|
||||
line := append(data, '\n')
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if err := os.MkdirAll(w.opts.LocalDir, 0750); err != nil {
|
||||
log.Errorf("mkdir aiproxy chat log dir: %v", err)
|
||||
w.rotateLocked(rec.Timestamp)
|
||||
w.buf = append(w.buf, line...)
|
||||
w.dirty = true
|
||||
w.kickFlushLocked()
|
||||
}
|
||||
|
||||
func (w *Writer) rotateLocked(ts time.Time) {
|
||||
key := w.objectKey(ts)
|
||||
if w.key == key {
|
||||
w.ensureLoadedLocked()
|
||||
return
|
||||
}
|
||||
path := filepath.Join(w.opts.LocalDir, logFileName(rec.Timestamp, w.opts.SegmentMinutes))
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if w.key != "" && w.dirty {
|
||||
w.pending = append(w.pending, flushItem{key: w.key, body: append([]byte(nil), w.buf...)})
|
||||
w.dirty = false
|
||||
}
|
||||
w.key = key
|
||||
w.buf = nil
|
||||
w.dirty = false
|
||||
w.loaded = false
|
||||
w.ensureLoadedLocked()
|
||||
}
|
||||
|
||||
func (w *Writer) ensureLoadedLocked() {
|
||||
if w.loaded || w.key == "" || !w.opts.UploadEnabled {
|
||||
return
|
||||
}
|
||||
store, err := w.getStoreLocked()
|
||||
if err != nil {
|
||||
log.Errorf("open aiproxy chat log: %v", err)
|
||||
log.Errorf("init aiproxy chat log store: %v", err)
|
||||
w.loaded = true
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
log.Errorf("write aiproxy chat log: %v", err)
|
||||
body, err := store.Get(context.Background(), w.key)
|
||||
if err != nil {
|
||||
if !isObjectNotFound(err) {
|
||||
log.Errorf("load aiproxy chat log %s: %v", w.key, err)
|
||||
}
|
||||
w.loaded = true
|
||||
return
|
||||
}
|
||||
w.buf = append([]byte(nil), body...)
|
||||
w.loaded = true
|
||||
}
|
||||
|
||||
func (w *Writer) getStoreLocked() (objectStore, error) {
|
||||
if w.store != nil {
|
||||
return w.store, nil
|
||||
}
|
||||
client, err := w.s3Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.store = &s3ObjectStore{
|
||||
client: client,
|
||||
bucket: w.opts.S3Bucket,
|
||||
}
|
||||
return w.store, nil
|
||||
}
|
||||
|
||||
func (w *Writer) kickFlushLocked() {
|
||||
if !w.opts.UploadEnabled || w.flushing {
|
||||
return
|
||||
}
|
||||
w.flushing = true
|
||||
go w.flushLoop()
|
||||
}
|
||||
|
||||
func (w *Writer) flushLoop() {
|
||||
for {
|
||||
w.mu.Lock()
|
||||
jobs := w.takeFlushJobsLocked()
|
||||
if len(jobs) == 0 {
|
||||
w.flushing = false
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
store, err := w.getStoreLocked()
|
||||
w.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Errorf("init aiproxy chat log store: %v", err)
|
||||
w.mu.Lock()
|
||||
w.requeueFlushJobsLocked(jobs)
|
||||
w.flushing = false
|
||||
w.flushErr = err
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if err := store.Put(context.Background(), job.key, job.body); err != nil {
|
||||
log.Errorf("put aiproxy chat log %s: %v", job.key, err)
|
||||
w.mu.Lock()
|
||||
w.requeueFlushJobsLocked([]flushItem{job})
|
||||
w.flushing = false
|
||||
w.flushErr = err
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.flushErr = nil
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) takeFlushJobsLocked() []flushItem {
|
||||
jobs := append([]flushItem(nil), w.pending...)
|
||||
w.pending = w.pending[:0]
|
||||
if w.dirty && w.key != "" {
|
||||
jobs = append(jobs, flushItem{key: w.key, body: append([]byte(nil), w.buf...)})
|
||||
w.dirty = false
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func (w *Writer) requeueFlushJobsLocked(jobs []flushItem) {
|
||||
for _, job := range jobs {
|
||||
if job.key == w.key {
|
||||
if !bytes.Equal(w.buf, job.body) {
|
||||
w.dirty = true
|
||||
continue
|
||||
}
|
||||
w.dirty = true
|
||||
continue
|
||||
}
|
||||
w.pending = append(w.pending, job)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) Flush(ctx context.Context) error {
|
||||
if w == nil || !w.opts.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !w.opts.UploadEnabled {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.kickFlushLocked()
|
||||
w.mu.Unlock()
|
||||
for {
|
||||
w.mu.Lock()
|
||||
done := !w.flushing && !w.dirty && len(w.pending) == 0
|
||||
stuck := !w.flushing && (w.dirty || len(w.pending) > 0)
|
||||
err := w.flushErr
|
||||
if stuck && err == nil {
|
||||
w.kickFlushLocked()
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if done {
|
||||
return err
|
||||
}
|
||||
if stuck && err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +381,6 @@ func FillUsageFromJSON(rec *Record, data []byte) bool {
|
||||
prompt := u.PromptTokens
|
||||
completion := u.CompletionTokens
|
||||
total := u.TotalTokens
|
||||
// Prefer OpenAI fields; fall back to Responses/Anthropic aliases.
|
||||
if prompt == 0 {
|
||||
prompt = u.InputTokens
|
||||
}
|
||||
@@ -295,6 +470,64 @@ func (w *Writer) s3Client() (*s3.Client, error) {
|
||||
}), nil
|
||||
}
|
||||
|
||||
type s3ObjectStore struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
ensureOnce sync.Once
|
||||
ensureError error
|
||||
}
|
||||
|
||||
func (s *s3ObjectStore) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if isObjectNotFound(err) {
|
||||
return nil, errObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer out.Body.Close()
|
||||
return io.ReadAll(out.Body)
|
||||
}
|
||||
|
||||
func (s *s3ObjectStore) Put(ctx context.Context, key string, body []byte) error {
|
||||
s.ensureOnce.Do(func() {
|
||||
s.ensureError = ensureBucket(ctx, s.client, s.bucket)
|
||||
})
|
||||
if s.ensureError != nil {
|
||||
return s.ensureError
|
||||
}
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func isObjectNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, errObjectNotFound) {
|
||||
return true
|
||||
}
|
||||
var nsk *types.NoSuchKey
|
||||
if errors.As(err, &nsk) {
|
||||
return true
|
||||
}
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NoSuchKey", "NotFound", "NoSuchBucket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hourObjectPrefix(prefix string, ts time.Time) string {
|
||||
prefix = strings.Trim(strings.TrimSpace(prefix), "/")
|
||||
key := "date=" + ts.Format("2006-01-02") + "/hour=" + ts.Format("15") + "/"
|
||||
@@ -330,95 +563,19 @@ func objectMatchesInstance(key string, instance string) bool {
|
||||
return suffix == "" || strings.HasSuffix(base, suffix)
|
||||
}
|
||||
|
||||
func markUploaded(dir, name string) error {
|
||||
return os.WriteFile(filepath.Join(dir, filepath.Base(name)+".uploaded"), []byte(time.Now().Format(time.RFC3339)), 0600)
|
||||
}
|
||||
|
||||
func finishUploaded(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return markUploaded(filepath.Dir(path), filepath.Base(path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploaded(dir, name string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, filepath.Base(name)+".uploaded"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func fileSegmentStart(name string) (time.Time, error) {
|
||||
return fileSegmentStartInLocation(name, time.Local)
|
||||
}
|
||||
|
||||
func fileSegmentStartInLocation(name string, loc *time.Location) (time.Time, error) {
|
||||
part := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(name), "chat-"), ".jsonl")
|
||||
if len(part) == len("20060102-1504") {
|
||||
return time.ParseInLocation(fileMinuteLayout, part, loc)
|
||||
}
|
||||
return time.ParseInLocation(fileHourLayout, part, loc)
|
||||
}
|
||||
|
||||
func closedSegmentFiles(dir string, now time.Time, segmentMinutes int) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
current := segmentStart(now, segmentMinutes)
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, "chat-") || !strings.HasSuffix(name, ".jsonl") || uploaded(dir, name) {
|
||||
continue
|
||||
}
|
||||
start, err := fileSegmentStartInLocation(name, now.Location())
|
||||
if err != nil || !start.Before(current) {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func StartUploader(ctx context.Context) {
|
||||
if defaultWriter == nil || !defaultWriter.opts.Enabled || !defaultWriter.opts.UploadEnabled {
|
||||
return
|
||||
}
|
||||
go defaultWriter.uploadLoop(ctx)
|
||||
}
|
||||
|
||||
func (w *Writer) uploadLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Duration(w.opts.UploadIntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
w.uploadClosedSegments(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.uploadClosedSegments(ctx)
|
||||
defaultWriter.mu.Lock()
|
||||
defaultWriter.rotateLocked(defaultWriter.now())
|
||||
defaultWriter.mu.Unlock()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if err := defaultWriter.Flush(context.Background()); err != nil {
|
||||
log.Errorf("flush aiproxy chat log on stop: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) uploadClosedSegments(ctx context.Context) {
|
||||
files, err := closedSegmentFiles(w.opts.LocalDir, time.Now(), w.opts.SegmentMinutes)
|
||||
if err != nil {
|
||||
log.Errorf("list aiproxy chat logs for upload: %v", err)
|
||||
return
|
||||
}
|
||||
for _, path := range files {
|
||||
if err := w.uploadFile(ctx, path); err != nil {
|
||||
log.Errorf("upload aiproxy chat log %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if err := finishUploaded(path); err != nil {
|
||||
log.Errorf("finish uploaded aiproxy chat log %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func Read(ctx context.Context, opts ReadOptions) (*ReadResult, error) {
|
||||
@@ -531,32 +688,6 @@ func readJSONLines(r io.Reader, opts ReadOptions, ret *ReadResult) error {
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (w *Writer) uploadFile(ctx context.Context, path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
ts, err := fileSegmentStart(filepath.Base(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := w.s3Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureBucket(ctx, client, w.opts.S3Bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
key := UploadKey(w.opts.S3Prefix, ts, path, w.opts.Instance)
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(w.opts.S3Bucket),
|
||||
Key: aws.String(key),
|
||||
Body: f,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureBucket(ctx context.Context, client *s3.Client, bucket string) error {
|
||||
_, err := client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
|
||||
if err == nil {
|
||||
|
||||
170
pkg/aiproxy/chatlog/chatlog_put_test.go
Normal file
170
pkg/aiproxy/chatlog/chatlog_put_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// 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 chatlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string][]byte
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{m: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (s *memStore) Get(_ context.Context, key string) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
body, ok := s.m[key]
|
||||
if !ok {
|
||||
return nil, errObjectNotFound
|
||||
}
|
||||
return append([]byte(nil), body...), nil
|
||||
}
|
||||
|
||||
func (s *memStore) Put(_ context.Context, key string, body []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.m[key] = append([]byte(nil), body...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestWriter(store objectStore) *Writer {
|
||||
w := NewWriter(Options{
|
||||
Enabled: true,
|
||||
UploadEnabled: true,
|
||||
Instance: "node-1",
|
||||
SegmentMinutes: 60,
|
||||
})
|
||||
w.store = store
|
||||
return w
|
||||
}
|
||||
|
||||
func TestWriterSameHourOverwriteJSONL(t *testing.T) {
|
||||
store := newMemStore()
|
||||
w := newTestWriter(store)
|
||||
ts := time.Date(2026, 8, 13, 10, 15, 0, 0, time.UTC)
|
||||
w.Write(&Record{RequestID: "a", Timestamp: ts})
|
||||
w.Write(&Record{RequestID: "b", Timestamp: ts.Add(time.Minute)})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := w.Flush(ctx); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
body, err := store.Get(ctx, w.objectKey(ts))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("got %d lines: %s", len(lines), body)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"request_id":"a"`) || !strings.Contains(lines[1], `"request_id":"b"`) {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSplitsObjectByHour(t *testing.T) {
|
||||
store := newMemStore()
|
||||
w := newTestWriter(store)
|
||||
h1 := time.Date(2026, 8, 13, 10, 50, 0, 0, time.UTC)
|
||||
h2 := time.Date(2026, 8, 13, 11, 5, 0, 0, time.UTC)
|
||||
w.Write(&Record{RequestID: "h1", Timestamp: h1})
|
||||
w.Write(&Record{RequestID: "h2", Timestamp: h2})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := w.Flush(ctx); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
b1, err := store.Get(ctx, w.objectKey(h1))
|
||||
if err != nil {
|
||||
t.Fatalf("get h1: %v", err)
|
||||
}
|
||||
b2, err := store.Get(ctx, w.objectKey(h2))
|
||||
if err != nil {
|
||||
t.Fatalf("get h2: %v", err)
|
||||
}
|
||||
if w.objectKey(h1) == w.objectKey(h2) {
|
||||
t.Fatal("expected different hour keys")
|
||||
}
|
||||
if bytes.Contains(b1, []byte(`"request_id":"h2"`)) || bytes.Contains(b2, []byte(`"request_id":"h1"`)) {
|
||||
t.Fatalf("hours mixed: h1=%s h2=%s", b1, b2)
|
||||
}
|
||||
if !bytes.Contains(b1, []byte(`"request_id":"h1"`)) || !bytes.Contains(b2, []byte(`"request_id":"h2"`)) {
|
||||
t.Fatalf("missing records: h1=%s h2=%s", b1, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterReloadAfterRestart(t *testing.T) {
|
||||
store := newMemStore()
|
||||
ts := time.Date(2026, 8, 13, 10, 20, 0, 0, time.UTC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
w1 := newTestWriter(store)
|
||||
w1.Write(&Record{RequestID: "old", Timestamp: ts})
|
||||
if err := w1.Flush(ctx); err != nil {
|
||||
t.Fatalf("flush w1: %v", err)
|
||||
}
|
||||
|
||||
w2 := newTestWriter(store)
|
||||
w2.Write(&Record{RequestID: "new", Timestamp: ts.Add(time.Minute)})
|
||||
if err := w2.Flush(ctx); err != nil {
|
||||
t.Fatalf("flush w2: %v", err)
|
||||
}
|
||||
body, err := store.Get(ctx, w2.objectKey(ts))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"request_id":"old"`)) || !bytes.Contains(body, []byte(`"request_id":"new"`)) {
|
||||
t.Fatalf("restart lost lines: %s", body)
|
||||
}
|
||||
if bytes.Count(body, []byte("\n")) != 2 {
|
||||
t.Fatalf("expected 2 lines, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterFlushUploadsPending(t *testing.T) {
|
||||
store := newMemStore()
|
||||
w := newTestWriter(store)
|
||||
ts := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
|
||||
w.Write(&Record{RequestID: "x", Timestamp: ts})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := w.Flush(ctx); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
body, err := store.Get(ctx, w.objectKey(ts))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"request_id":"x"`)) {
|
||||
t.Fatalf("flush did not upload: %s", body)
|
||||
}
|
||||
w.mu.Lock()
|
||||
dirty, pending := w.dirty, len(w.pending)
|
||||
w.mu.Unlock()
|
||||
if dirty || pending != 0 {
|
||||
t.Fatalf("buffer still dirty after flush: dirty=%v pending=%d", dirty, pending)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/models"
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/providers/openai"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
)
|
||||
|
||||
var ErrVisualStreamingUnsupported = fmt.Errorf("visual extension does not support streaming")
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
"fmt"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/models"
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/providers/openai"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
)
|
||||
|
||||
// ShouldHandle reports whether the Responses visual orchestration path should run.
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"yunion.io/x/pkg/errors"
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ func fillAPILogFromUpstream(rec *chatlog.Record, up *models.ChatUpstream) {
|
||||
rec.AiKey = up.AiKeyId
|
||||
rec.ModelFinal = up.UpstreamModel
|
||||
rec.Provider = up.ProviderKey
|
||||
rec.AiProviderId = up.AiProviderId
|
||||
if up.RoutingLog != nil {
|
||||
rec.RoutingEnabled = up.RoutingLog.Enabled
|
||||
rec.RoutingCandidates = up.RoutingLog.Candidates
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"yunion.io/x/pkg/appctx"
|
||||
"yunion.io/x/pkg/util/printutils"
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
"yunion.io/x/pkg/util/timeutils"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/chatlog"
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/models"
|
||||
@@ -25,10 +26,10 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
common_policy "yunion.io/x/onecloud/pkg/cloudcommon/policy"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/auth"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
|
||||
"yunion.io/x/onecloud/pkg/util/excelutils"
|
||||
"yunion.io/x/onecloud/pkg/util/rbacutils"
|
||||
)
|
||||
|
||||
type usageFilter struct {
|
||||
@@ -47,6 +48,12 @@ type usageFilter struct {
|
||||
Result string
|
||||
Limit int
|
||||
Offset int
|
||||
|
||||
Scope rbacscope.TRbacScope
|
||||
ProjectID string
|
||||
DomainID string
|
||||
QueryProject string
|
||||
QueryDomain string
|
||||
}
|
||||
|
||||
type aiProxyUsageManager struct{}
|
||||
@@ -70,15 +77,15 @@ func (m aiProxyUsageManager) List() []api.UsageResource {
|
||||
return append([]api.UsageResource(nil), aiProxyUsageResources...)
|
||||
}
|
||||
|
||||
func (m aiProxyUsageManager) Get(ctx context.Context, id string, query jsonutils.JSONObject) (interface{}, error) {
|
||||
func (m aiProxyUsageManager) Get(ctx context.Context, id string, query jsonutils.JSONObject, owner mcclient.IIdentityProvider, scope rbacscope.TRbacScope) (interface{}, error) {
|
||||
switch id {
|
||||
case "overview", "analysis", "events", "api-keys-options":
|
||||
default:
|
||||
return nil, httperrors.NewResourceNotFoundError2("ai_proxy_usage", id)
|
||||
}
|
||||
filter, err := parseUsageFilterQuery(query)
|
||||
filter, err := loadUsageFilter(ctx, query, owner, scope)
|
||||
if err != nil {
|
||||
return nil, httperrors.NewInputParameterError("%v", err)
|
||||
return nil, err
|
||||
}
|
||||
readLimit := maxUsageReadLimit
|
||||
pushdownFilter := false
|
||||
@@ -92,7 +99,7 @@ func (m aiProxyUsageManager) Get(ctx context.Context, id string, query jsonutils
|
||||
}
|
||||
switch id {
|
||||
case "overview":
|
||||
overview := buildUsageOverview(records, filter)
|
||||
overview := buildUsageOverview(records, filter, resolveUsageNames(records))
|
||||
overview.Truncated = truncated
|
||||
return overview, nil
|
||||
case "analysis":
|
||||
@@ -153,21 +160,24 @@ type usageNames struct {
|
||||
AiKeys map[string]string
|
||||
Projects map[string]string
|
||||
Domains map[string]string
|
||||
Providers map[string]string
|
||||
}
|
||||
|
||||
func resolveUsageNames(records []chatlog.Record) usageNames {
|
||||
var virtualKeyIds, aiKeyIds, projectIds, domainIds []string
|
||||
var virtualKeyIds, aiKeyIds, projectIds, domainIds, providerIds []string
|
||||
for _, rec := range records {
|
||||
virtualKeyIds = appendUsageNameId(virtualKeyIds, rec.VirtualKey)
|
||||
aiKeyIds = appendUsageNameId(aiKeyIds, rec.AiKey)
|
||||
projectIds = appendUsageNameId(projectIds, rec.ProjectID)
|
||||
domainIds = appendUsageNameId(domainIds, rec.DomainID)
|
||||
providerIds = appendUsageNameId(providerIds, rec.AiProviderId)
|
||||
}
|
||||
return usageNames{
|
||||
VirtualKeys: fetchUsageNameMap(models.AiVirtualKeyManager, virtualKeyIds, "ai_virtual_key"),
|
||||
AiKeys: fetchUsageNameMap(models.AiKeyManager, aiKeyIds, "ai_key"),
|
||||
Projects: fetchUsageNameMap(db.TenantCacheManager, projectIds, "project"),
|
||||
Domains: fetchUsageNameMap(db.TenantCacheManager, domainIds, "domain"),
|
||||
Providers: fetchUsageNameMap(models.AiProviderManager, providerIds, "ai_provider"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +190,7 @@ func appendUsageNameId(ids []string, id string) []string {
|
||||
}
|
||||
|
||||
func fetchUsageNameMap(manager db.IStandaloneModelManager, ids []string, resource string) map[string]string {
|
||||
if len(ids) == 0 {
|
||||
if manager == nil || len(ids) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
ret, err := db.FetchIdNameMap2(manager, ids)
|
||||
@@ -202,8 +212,23 @@ func usageName(names map[string]string, id string) string {
|
||||
return id
|
||||
}
|
||||
|
||||
func recordProviderID(rec chatlog.Record) string {
|
||||
if id := strings.TrimSpace(rec.AiProviderId); id != "" {
|
||||
return id
|
||||
}
|
||||
return strings.TrimSpace(rec.Provider)
|
||||
}
|
||||
|
||||
func recordProviderName(rec chatlog.Record, names usageNames) string {
|
||||
if id := strings.TrimSpace(rec.AiProviderId); id != "" {
|
||||
return usageName(names.Providers, id)
|
||||
}
|
||||
return strings.TrimSpace(rec.Provider)
|
||||
}
|
||||
|
||||
func aiProxyUsageListHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !checkUsageAccess(ctx, w) {
|
||||
_, query, _ := appsrv.FetchEnv(ctx, w, r)
|
||||
if _, _, ok := resolveUsageAccess(ctx, w, query); !ok {
|
||||
return
|
||||
}
|
||||
body := jsonutils.NewDict()
|
||||
@@ -217,19 +242,20 @@ func aiProxyUsageListHandler(ctx context.Context, w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func aiProxyUsageGetHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !checkUsageAccess(ctx, w) {
|
||||
return
|
||||
}
|
||||
params := appctx.AppContextParams(ctx)
|
||||
_, query, _ := appsrv.FetchEnv(ctx, w, r)
|
||||
owner, scope, ok := resolveUsageAccess(ctx, w, query)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := params["<id>"]
|
||||
if id == "events" && usageEventsExportRequested(query) {
|
||||
if err := aiProxyUsage.ExportEvents(ctx, w, query); err != nil {
|
||||
if err := aiProxyUsage.ExportEvents(ctx, w, query, owner, scope); err != nil {
|
||||
httperrors.JsonClientError(ctx, w, httperrors.NewGeneralError(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
result, err := aiProxyUsage.Get(ctx, id, query)
|
||||
result, err := aiProxyUsage.Get(ctx, id, query, owner, scope)
|
||||
if err != nil {
|
||||
httperrors.JsonClientError(ctx, w, httperrors.NewGeneralError(err))
|
||||
return
|
||||
@@ -243,13 +269,13 @@ func aiProxyUsageGetHandler(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
appsrv.SendJSON(w, body)
|
||||
}
|
||||
|
||||
func (m aiProxyUsageManager) ExportEvents(ctx context.Context, w http.ResponseWriter, query jsonutils.JSONObject) error {
|
||||
func (m aiProxyUsageManager) ExportEvents(ctx context.Context, w http.ResponseWriter, query jsonutils.JSONObject, owner mcclient.IIdentityProvider, scope rbacscope.TRbacScope) error {
|
||||
if _, _, _, err := usageEventsExportParams(query); err != nil {
|
||||
return err
|
||||
}
|
||||
filter, err := parseUsageFilterQuery(query)
|
||||
filter, err := loadUsageFilter(ctx, query, owner, scope)
|
||||
if err != nil {
|
||||
return httperrors.NewInputParameterError("%v", err)
|
||||
return err
|
||||
}
|
||||
filter.Limit = usageEventsExportLimit(query)
|
||||
records, _, err := m.read(ctx, filter, eventReadLimit(filter), true)
|
||||
@@ -320,11 +346,12 @@ func usageEventsExportParams(query jsonutils.JSONObject) ([]string, []string, st
|
||||
}
|
||||
|
||||
func aiProxyUsageEventsDistinctFieldHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !checkUsageAccess(ctx, w) {
|
||||
_, query, _ := appsrv.FetchEnv(ctx, w, r)
|
||||
owner, scope, ok := resolveUsageAccess(ctx, w, query)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, query, _ := appsrv.FetchEnv(ctx, w, r)
|
||||
result, err := aiProxyUsage.DistinctField(ctx, query)
|
||||
result, err := aiProxyUsage.DistinctField(ctx, query, owner, scope)
|
||||
if err != nil {
|
||||
httperrors.JsonClientError(ctx, w, httperrors.NewGeneralError(err))
|
||||
return
|
||||
@@ -340,14 +367,14 @@ func usageListResultJSON(list usageListResult) jsonutils.JSONObject {
|
||||
return body
|
||||
}
|
||||
|
||||
func (m aiProxyUsageManager) DistinctField(ctx context.Context, query jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
||||
func (m aiProxyUsageManager) DistinctField(ctx context.Context, query jsonutils.JSONObject, owner mcclient.IIdentityProvider, scope rbacscope.TRbacScope) (jsonutils.JSONObject, error) {
|
||||
field, err := distinctFieldName(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter, err := parseUsageFilterQuery(query)
|
||||
filter, err := loadUsageFilter(ctx, query, owner, scope)
|
||||
if err != nil {
|
||||
return nil, httperrors.NewInputParameterError("%v", err)
|
||||
return nil, err
|
||||
}
|
||||
records, _, err := m.read(ctx, filter, maxUsageReadLimit, true)
|
||||
if err != nil {
|
||||
@@ -368,14 +395,76 @@ func distinctFieldName(query jsonutils.JSONObject) (string, error) {
|
||||
return field, nil
|
||||
}
|
||||
|
||||
func checkUsageAccess(ctx context.Context, w http.ResponseWriter) bool {
|
||||
type aiProxyUsageScopedManager struct{}
|
||||
|
||||
func (m aiProxyUsageScopedManager) KeywordPlural() string {
|
||||
return "ai_proxy_usage"
|
||||
}
|
||||
|
||||
func (m aiProxyUsageScopedManager) ResourceScope() rbacscope.TRbacScope {
|
||||
return rbacscope.ScopeProject
|
||||
}
|
||||
|
||||
func (m aiProxyUsageScopedManager) FetchOwnerId(ctx context.Context, data jsonutils.JSONObject) (mcclient.IIdentityProvider, error) {
|
||||
return db.FetchProjectInfo(ctx, data)
|
||||
}
|
||||
|
||||
func resolveUsageAccess(ctx context.Context, w http.ResponseWriter, query jsonutils.JSONObject) (mcclient.IIdentityProvider, rbacscope.TRbacScope, bool) {
|
||||
userCred := auth.FetchUserCredential(ctx, common_policy.FilterPolicyCredential)
|
||||
result := common_policy.PolicyManager.Allow(rbacscope.ScopeSystem, userCred, api.SERVICE_TYPE, "usage", common_policy.PolicyActionList)
|
||||
if result.Result == rbacutils.Deny {
|
||||
httperrors.ForbiddenError(ctx, w, "Not allow to access")
|
||||
return false
|
||||
if query == nil {
|
||||
query = jsonutils.NewDict()
|
||||
}
|
||||
return true
|
||||
ownerId, scope, err, _ := db.FetchCheckQueryOwnerScope(ctx, userCred, query, aiProxyUsageScopedManager{}, common_policy.PolicyActionGet, true)
|
||||
if err != nil {
|
||||
httperrors.JsonClientError(ctx, w, httperrors.NewGeneralError(err))
|
||||
return nil, rbacscope.ScopeNone, false
|
||||
}
|
||||
return ownerId, scope, true
|
||||
}
|
||||
|
||||
func loadUsageFilter(ctx context.Context, query jsonutils.JSONObject, owner mcclient.IIdentityProvider, scope rbacscope.TRbacScope) (usageFilter, error) {
|
||||
filter, err := parseUsageFilterQuery(query)
|
||||
if err != nil {
|
||||
return usageFilter{}, httperrors.NewInputParameterError("%v", err)
|
||||
}
|
||||
if err := resolveUsageQueryProjectDomain(ctx, &filter); err != nil {
|
||||
return usageFilter{}, httperrors.NewInputParameterError("%v", err)
|
||||
}
|
||||
applyUsageOwner(&filter, owner, scope)
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func applyUsageOwner(filter *usageFilter, owner mcclient.IIdentityProvider, scope rbacscope.TRbacScope) {
|
||||
if filter == nil {
|
||||
return
|
||||
}
|
||||
filter.Scope = scope
|
||||
if owner == nil {
|
||||
return
|
||||
}
|
||||
filter.ProjectID = owner.GetProjectId()
|
||||
filter.DomainID = owner.GetProjectDomainId()
|
||||
}
|
||||
|
||||
func resolveUsageQueryProjectDomain(ctx context.Context, filter *usageFilter) error {
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
if filter.QueryDomain != "" {
|
||||
domain, err := db.TenantCacheManager.FetchDomainByIdOrName(ctx, filter.QueryDomain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid domain: %w", err)
|
||||
}
|
||||
filter.QueryDomain = domain.Id
|
||||
}
|
||||
if filter.QueryProject != "" {
|
||||
project, err := db.TenantCacheManager.FetchTenantByIdOrNameInDomain(ctx, filter.QueryProject, filter.QueryDomain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid project: %w", err)
|
||||
}
|
||||
filter.QueryProject = project.Id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseUsageFilterQuery(query jsonutils.JSONObject) (usageFilter, error) {
|
||||
@@ -404,10 +493,14 @@ func parseUsageFilter(r *http.Request) (usageFilter, error) {
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
rng := strings.TrimSpace(q.Get("range"))
|
||||
if rng == "" {
|
||||
rawStart := strings.TrimSpace(q.Get("start"))
|
||||
rawEnd := strings.TrimSpace(q.Get("end"))
|
||||
if rng == "" && rawStart == "" && rawEnd == "" {
|
||||
rng = "24h"
|
||||
} else if rng == "" && rawStart != "" && rawEnd != "" {
|
||||
rng = "custom"
|
||||
}
|
||||
start, end, err := usageRange(rng, q.Get("start"), q.Get("end"), now)
|
||||
start, end, err := usageRange(rng, rawStart, rawEnd, now)
|
||||
if err != nil {
|
||||
return usageFilter{}, err
|
||||
}
|
||||
@@ -432,20 +525,22 @@ func parseUsageFilter(r *http.Request) (usageFilter, error) {
|
||||
}
|
||||
}
|
||||
return usageFilter{
|
||||
Range: rng,
|
||||
Start: start,
|
||||
End: end,
|
||||
Timezone: timezone,
|
||||
APIKeyID: strings.TrimSpace(q.Get("api_key_id")),
|
||||
RequestID: requestID,
|
||||
RequestIDs: requestIDs,
|
||||
Model: strings.TrimSpace(q.Get("model")),
|
||||
Provider: strings.TrimSpace(q.Get("provider")),
|
||||
Source: strings.TrimSpace(q.Get("source")),
|
||||
AuthIndex: strings.TrimSpace(q.Get("auth_index")),
|
||||
Result: strings.TrimSpace(q.Get("result")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Range: rng,
|
||||
Start: start,
|
||||
End: end,
|
||||
Timezone: timezone,
|
||||
APIKeyID: strings.TrimSpace(q.Get("api_key_id")),
|
||||
RequestID: requestID,
|
||||
RequestIDs: requestIDs,
|
||||
Model: strings.TrimSpace(q.Get("model")),
|
||||
Provider: strings.TrimSpace(q.Get("provider")),
|
||||
Source: strings.TrimSpace(q.Get("source")),
|
||||
AuthIndex: strings.TrimSpace(q.Get("auth_index")),
|
||||
Result: strings.TrimSpace(q.Get("result")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
QueryProject: firstNonEmpty(q.Get("project"), q.Get("project_id"), q.Get("tenant_id"), q.Get("tenant")),
|
||||
QueryDomain: firstNonEmpty(q.Get("domain"), q.Get("domain_id"), q.Get("project_domain")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -483,6 +578,14 @@ func parseUsageRequestIDIn(q url.Values) map[string]struct{} {
|
||||
}
|
||||
|
||||
func usageRange(rng, rawStart, rawEnd string, now time.Time) (time.Time, time.Time, error) {
|
||||
rawStart = strings.TrimSpace(rawStart)
|
||||
rawEnd = strings.TrimSpace(rawEnd)
|
||||
if rawStart != "" || rawEnd != "" {
|
||||
return usageCustomRange(rawStart, rawEnd, now.Location())
|
||||
}
|
||||
if rng == "" {
|
||||
rng = "24h"
|
||||
}
|
||||
switch rng {
|
||||
case "4h", "8h", "12h", "24h":
|
||||
d, _ := time.ParseDuration(rng)
|
||||
@@ -498,23 +601,73 @@ func usageRange(rng, rawStart, rawEnd string, now time.Time) (time.Time, time.Ti
|
||||
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return end.AddDate(0, 0, -1), end, nil
|
||||
case "custom":
|
||||
start, err := time.Parse(time.RFC3339, strings.TrimSpace(rawStart))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, strings.TrimSpace(rawEnd))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if !end.After(start) {
|
||||
return time.Time{}, time.Time{}, errors.New("end must be after start")
|
||||
}
|
||||
return start, end, nil
|
||||
return time.Time{}, time.Time{}, errors.New("start and end are required")
|
||||
default:
|
||||
return time.Time{}, time.Time{}, errors.New("unsupported range")
|
||||
}
|
||||
}
|
||||
|
||||
func usageCustomRange(rawStart, rawEnd string, loc *time.Location) (time.Time, time.Time, error) {
|
||||
if rawStart == "" || rawEnd == "" {
|
||||
return time.Time{}, time.Time{}, errors.New("start and end are required")
|
||||
}
|
||||
start, err := parseUsageTime(rawStart, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start: %w", err)
|
||||
}
|
||||
end, err := parseUsageTime(rawEnd, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end: %w", err)
|
||||
}
|
||||
if !end.After(start) {
|
||||
return time.Time{}, time.Time{}, errors.New("end must be after start")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func parseUsageTime(raw string, loc *time.Location) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, errors.New("empty time")
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
if unix, ok := parseUsageUnix(raw); ok {
|
||||
return unix.In(loc), nil
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return ts, nil
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return ts, nil
|
||||
}
|
||||
ts, err := timeutils.ParseTimeStrInLocation(raw, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("unknown time format %s", raw)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func parseUsageUnix(raw string) (time.Time, bool) {
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, c := range raw {
|
||||
if c < '0' || c > '9' {
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if n >= 1e12 {
|
||||
return time.UnixMilli(n), true
|
||||
}
|
||||
return time.Unix(n, 0), true
|
||||
}
|
||||
|
||||
func filterRecords(records []chatlog.Record, filter usageFilter) []chatlog.Record {
|
||||
ret := make([]chatlog.Record, 0, len(records))
|
||||
for _, rec := range records {
|
||||
@@ -526,6 +679,9 @@ func filterRecords(records []chatlog.Record, filter usageFilter) []chatlog.Recor
|
||||
}
|
||||
|
||||
func recordMatchesFilter(rec chatlog.Record, filter usageFilter) bool {
|
||||
if !recordMatchesOwner(rec, filter) {
|
||||
return false
|
||||
}
|
||||
if len(filter.RequestIDs) > 0 {
|
||||
if _, ok := filter.RequestIDs[rec.RequestID]; !ok {
|
||||
return false
|
||||
@@ -552,24 +708,45 @@ func recordMatchesFilter(rec chatlog.Record, filter usageFilter) bool {
|
||||
if filter.Result == "failed" && rec.Success {
|
||||
return false
|
||||
}
|
||||
if filter.QueryProject != "" && rec.ProjectID != filter.QueryProject {
|
||||
return false
|
||||
}
|
||||
if filter.QueryDomain != "" && rec.DomainID != filter.QueryDomain {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildUsageOverview(records []chatlog.Record, filter usageFilter) api.UsageOverview {
|
||||
func recordMatchesOwner(rec chatlog.Record, filter usageFilter) bool {
|
||||
switch filter.Scope {
|
||||
case rbacscope.ScopeProject:
|
||||
return rec.ProjectID != "" && rec.ProjectID == filter.ProjectID
|
||||
case rbacscope.ScopeDomain:
|
||||
return rec.DomainID != "" && rec.DomainID == filter.DomainID
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func buildUsageOverview(records []chatlog.Record, filter usageFilter, names usageNames) api.UsageOverview {
|
||||
durationMinutes := filter.End.Sub(filter.Start).Minutes()
|
||||
if durationMinutes <= 0 {
|
||||
durationMinutes = 1
|
||||
}
|
||||
overview := api.UsageOverview{
|
||||
Timezone: filter.Timezone,
|
||||
RangeStart: filter.Start,
|
||||
RangeEnd: filter.End,
|
||||
Series: []api.UsageOverviewPoint{},
|
||||
ServiceHealth: []api.UsageServiceHealth{},
|
||||
Timezone: filter.Timezone,
|
||||
RangeStart: filter.Start,
|
||||
RangeEnd: filter.End,
|
||||
Series: []api.UsageOverviewPoint{},
|
||||
ServiceHealth: []api.UsageServiceHealth{},
|
||||
APIKeyComposition: []api.UsageComposition{},
|
||||
AIKeyComposition: []api.UsageComposition{},
|
||||
}
|
||||
bucketSize := overviewBucketSize(filter.End.Sub(filter.Start))
|
||||
series := map[time.Time]*api.UsageOverviewPoint{}
|
||||
health := map[string]*api.UsageServiceHealth{}
|
||||
apiKeys := map[string]*api.UsageComposition{}
|
||||
aiKeys := map[string]*api.UsageComposition{}
|
||||
latencySum := int64(0)
|
||||
latencyCount := 0
|
||||
healthLatencySum := map[string]int64{}
|
||||
@@ -607,10 +784,15 @@ func buildUsageOverview(records []chatlog.Record, filter usageFilter) api.UsageO
|
||||
point.FailureCount++
|
||||
}
|
||||
|
||||
key := rec.Provider + "\x00" + recordModel(rec)
|
||||
providerID := recordProviderID(rec)
|
||||
key := providerID + "\x00" + recordModel(rec)
|
||||
row := health[key]
|
||||
if row == nil {
|
||||
row = &api.UsageServiceHealth{Provider: rec.Provider, Model: recordModel(rec)}
|
||||
row = &api.UsageServiceHealth{
|
||||
Provider: rec.Provider,
|
||||
ProviderName: recordProviderName(rec, names),
|
||||
Model: recordModel(rec),
|
||||
}
|
||||
health[key] = row
|
||||
}
|
||||
row.RequestCount++
|
||||
@@ -625,6 +807,11 @@ func buildUsageOverview(records []chatlog.Record, filter usageFilter) api.UsageO
|
||||
healthLatencySum[key] += rec.LatencyMs
|
||||
healthLatencyCount[key]++
|
||||
}
|
||||
|
||||
apiKeyName := usageName(names.VirtualKeys, rec.VirtualKey)
|
||||
addComposition(apiKeys, rec.VirtualKey, apiKeyName, apiKeyName, rec, tokens)
|
||||
aiKeyName := usageName(names.AiKeys, rec.AiKey)
|
||||
addComposition(aiKeys, rec.AiKey, aiKeyName, aiKeyName, rec, tokens)
|
||||
}
|
||||
|
||||
overview.Summary.RPM = float64(overview.Summary.RequestCount) / durationMinutes
|
||||
@@ -661,16 +848,20 @@ func buildUsageOverview(records []chatlog.Record, filter usageFilter) api.UsageO
|
||||
if overview.ServiceHealth[i].RequestCount != overview.ServiceHealth[j].RequestCount {
|
||||
return overview.ServiceHealth[i].RequestCount > overview.ServiceHealth[j].RequestCount
|
||||
}
|
||||
if overview.ServiceHealth[i].Provider != overview.ServiceHealth[j].Provider {
|
||||
return overview.ServiceHealth[i].Provider < overview.ServiceHealth[j].Provider
|
||||
left := firstNonEmpty(overview.ServiceHealth[i].ProviderName, overview.ServiceHealth[i].Provider)
|
||||
right := firstNonEmpty(overview.ServiceHealth[j].ProviderName, overview.ServiceHealth[j].Provider)
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return overview.ServiceHealth[i].Model < overview.ServiceHealth[j].Model
|
||||
})
|
||||
overview.APIKeyComposition = sortedCompositions(apiKeys)
|
||||
overview.AIKeyComposition = sortedCompositions(aiKeys)
|
||||
return overview
|
||||
}
|
||||
|
||||
func buildUsageAnalysis(records []chatlog.Record, filter usageFilter, names usageNames) api.UsageAnalysis {
|
||||
overview := buildUsageOverview(records, filter)
|
||||
overview := buildUsageOverview(records, filter, names)
|
||||
analysis := api.UsageAnalysis{
|
||||
TokenUsage: overview.Series,
|
||||
APIKeyComposition: []api.UsageComposition{},
|
||||
@@ -701,7 +892,7 @@ func buildUsageAnalysis(records []chatlog.Record, filter usageFilter, names usag
|
||||
addComposition(apiKeys, rec.VirtualKey, apiKeyName, apiKeyName, rec, tokens)
|
||||
addComposition(models, modelName, modelName, "", rec, tokens)
|
||||
addComposition(authFiles, rec.AiKey, authFileName, authFileName, rec, tokens)
|
||||
addComposition(providers, rec.Provider, rec.Provider, "", rec, tokens)
|
||||
addComposition(providers, recordProviderID(rec), recordProviderName(rec, names), "", rec, tokens)
|
||||
weekday := rec.Timestamp.Weekday().String()
|
||||
heatKey := weekday + "\x00" + strconv.Itoa(rec.Timestamp.Hour())
|
||||
point := heatmap[heatKey]
|
||||
@@ -779,6 +970,7 @@ func buildUsageEvents(records []chatlog.Record, filter usageFilter, names usageN
|
||||
Endpoint: rec.Path,
|
||||
Source: recordSource(rec),
|
||||
Provider: rec.Provider,
|
||||
ProviderName: recordProviderName(rec, names),
|
||||
AuthIndex: rec.AiKey,
|
||||
AuthIndexName: authIndexName,
|
||||
AuthIndexLabel: authIndexName,
|
||||
@@ -986,7 +1178,7 @@ func recordModel(rec chatlog.Record) string {
|
||||
}
|
||||
|
||||
func recordSource(rec chatlog.Record) string {
|
||||
return firstNonEmpty(rec.Provider, rec.AiKey)
|
||||
return strings.TrimSpace(rec.Path)
|
||||
}
|
||||
|
||||
func recordTotalTokens(rec chatlog.Record) int {
|
||||
|
||||
128
pkg/aiproxy/handlers/usage_overview_test.go
Normal file
128
pkg/aiproxy/handlers/usage_overview_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/chatlog"
|
||||
)
|
||||
|
||||
func TestBuildUsageProviderNameAndAPIKeyComposition(t *testing.T) {
|
||||
now := time.Now()
|
||||
filter := usageFilter{Start: now.Add(-time.Hour), End: now, Timezone: "UTC"}
|
||||
records := []chatlog.Record{
|
||||
{AiProviderId: "p1", Provider: "openai", VirtualKey: "vk1", ModelFinal: "gpt-4", PromptTokens: 10, CompletionTokens: 20, Success: true, Timestamp: now},
|
||||
{AiProviderId: "p2", Provider: "openai", VirtualKey: "vk1", ModelFinal: "gpt-4", PromptTokens: 5, CompletionTokens: 5, Success: true, Timestamp: now},
|
||||
{Provider: "openai", VirtualKey: "vk2", ModelFinal: "gpt-4", PromptTokens: 1, CompletionTokens: 1, Success: true, Timestamp: now},
|
||||
}
|
||||
names := usageNames{
|
||||
Providers: map[string]string{"p1": "Prod OpenAI", "p2": "Test OpenAI"},
|
||||
VirtualKeys: map[string]string{"vk1": "key-one", "vk2": "key-two"},
|
||||
}
|
||||
|
||||
overview := buildUsageOverview(records, filter, names)
|
||||
if len(overview.ServiceHealth) != 3 {
|
||||
t.Fatalf("service health got %d want 3", len(overview.ServiceHealth))
|
||||
}
|
||||
healthNames := map[string]int{}
|
||||
for _, row := range overview.ServiceHealth {
|
||||
healthNames[row.ProviderName] += row.RequestCount
|
||||
if row.Provider != "openai" {
|
||||
t.Fatalf("provider key should stay openai, got %q", row.Provider)
|
||||
}
|
||||
}
|
||||
if healthNames["Prod OpenAI"] != 1 || healthNames["Test OpenAI"] != 1 || healthNames["openai"] != 1 {
|
||||
t.Fatalf("service health names: %+v", healthNames)
|
||||
}
|
||||
|
||||
if len(overview.APIKeyComposition) != 2 {
|
||||
t.Fatalf("api key composition got %d want 2", len(overview.APIKeyComposition))
|
||||
}
|
||||
byKey := map[string]int{}
|
||||
byToken := map[string]int{}
|
||||
byName := map[string]string{}
|
||||
for _, item := range overview.APIKeyComposition {
|
||||
byKey[item.ID] = item.RequestCount
|
||||
byToken[item.ID] = item.TokenCount
|
||||
byName[item.ID] = item.Name
|
||||
}
|
||||
if byName["vk1"] != "key-one" || byKey["vk1"] != 2 || byToken["vk1"] != 40 {
|
||||
t.Fatalf("vk1 composition name=%s req=%d tokens=%d", byName["vk1"], byKey["vk1"], byToken["vk1"])
|
||||
}
|
||||
if byName["vk2"] != "key-two" || byKey["vk2"] != 1 || byToken["vk2"] != 2 {
|
||||
t.Fatalf("vk2 composition name=%s req=%d tokens=%d", byName["vk2"], byKey["vk2"], byToken["vk2"])
|
||||
}
|
||||
|
||||
analysis := buildUsageAnalysis(records, filter, names)
|
||||
if len(analysis.AIProviderComposition) != 3 {
|
||||
t.Fatalf("provider composition got %d want 3", len(analysis.AIProviderComposition))
|
||||
}
|
||||
byProv := map[string]string{}
|
||||
for _, item := range analysis.AIProviderComposition {
|
||||
byProv[item.ID] = item.Name
|
||||
}
|
||||
if byProv["p1"] != "Prod OpenAI" || byProv["p2"] != "Test OpenAI" || byProv["openai"] != "openai" {
|
||||
t.Fatalf("provider composition: %+v", byProv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordProviderIDAndName(t *testing.T) {
|
||||
names := usageNames{Providers: map[string]string{"p1": "Named"}}
|
||||
withID := chatlog.Record{AiProviderId: "p1", Provider: "openai"}
|
||||
if recordProviderID(withID) != "p1" {
|
||||
t.Fatal("expected provider id")
|
||||
}
|
||||
if recordProviderName(withID, names) != "Named" {
|
||||
t.Fatal("expected resolved provider name")
|
||||
}
|
||||
legacy := chatlog.Record{Provider: "openai"}
|
||||
if recordProviderID(legacy) != "openai" {
|
||||
t.Fatal("legacy should fall back to provider key")
|
||||
}
|
||||
if recordProviderName(legacy, names) != "openai" {
|
||||
t.Fatal("legacy name should be provider key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSourceUsesPath(t *testing.T) {
|
||||
path := "/v1/chat/completions"
|
||||
rec := chatlog.Record{Path: path, Provider: "deepseek", AiKey: "ak-1"}
|
||||
if got := recordSource(rec); got != path {
|
||||
t.Fatalf("source=%q want path %q", got, path)
|
||||
}
|
||||
empty := chatlog.Record{Provider: "deepseek", AiKey: "ak-1"}
|
||||
if got := recordSource(empty); got != "" {
|
||||
t.Fatalf("empty path should not fall back to provider, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsageAIKeyComposition(t *testing.T) {
|
||||
now := time.Now()
|
||||
filter := usageFilter{Start: now.Add(-time.Hour), End: now, Timezone: "UTC"}
|
||||
records := []chatlog.Record{
|
||||
{AiKey: "ak-1", VirtualKey: "vk1", ModelFinal: "gpt-4", PromptTokens: 10, CompletionTokens: 20, Success: true, Timestamp: now},
|
||||
{AiKey: "ak-1", VirtualKey: "vk2", ModelFinal: "gpt-4", PromptTokens: 5, CompletionTokens: 5, Success: true, Timestamp: now},
|
||||
{AiKey: "ak-2", VirtualKey: "vk2", ModelFinal: "gpt-4", PromptTokens: 1, CompletionTokens: 1, Success: true, Timestamp: now},
|
||||
}
|
||||
names := usageNames{
|
||||
AiKeys: map[string]string{"ak-1": "deepseek-yunion-key", "ak-2": "user1-deepseek-key"},
|
||||
}
|
||||
overview := buildUsageOverview(records, filter, names)
|
||||
if len(overview.AIKeyComposition) != 2 {
|
||||
t.Fatalf("ai key composition got %d want 2", len(overview.AIKeyComposition))
|
||||
}
|
||||
byReq := map[string]int{}
|
||||
byToken := map[string]int{}
|
||||
byName := map[string]string{}
|
||||
for _, item := range overview.AIKeyComposition {
|
||||
byReq[item.ID] = item.RequestCount
|
||||
byToken[item.ID] = item.TokenCount
|
||||
byName[item.ID] = item.Name
|
||||
}
|
||||
if byName["ak-1"] != "deepseek-yunion-key" || byReq["ak-1"] != 2 || byToken["ak-1"] != 40 {
|
||||
t.Fatalf("ak-1 composition name=%s req=%d tokens=%d", byName["ak-1"], byReq["ak-1"], byToken["ak-1"])
|
||||
}
|
||||
if byName["ak-2"] != "user1-deepseek-key" || byReq["ak-2"] != 1 || byToken["ak-2"] != 2 {
|
||||
t.Fatalf("ak-2 composition name=%s req=%d tokens=%d", byName["ak-2"], byReq["ak-2"], byToken["ak-2"])
|
||||
}
|
||||
}
|
||||
140
pkg/aiproxy/handlers/usage_range_test.go
Normal file
140
pkg/aiproxy/handlers/usage_range_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUsageRangePreset24h(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
start, end, err := usageRange("24h", "", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !end.Equal(now) {
|
||||
t.Fatalf("end = %v, want %v", end, now)
|
||||
}
|
||||
if !start.Equal(now.Add(-24 * time.Hour)) {
|
||||
t.Fatalf("start = %v, want %v", start, now.Add(-24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeStartEndWithoutRange(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
start, end, err := usageRange("", "2026-08-12T00:00:00Z", "2026-08-13T00:00:00Z", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStart := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
wantEnd := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
|
||||
if !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("got %v %v, want %v %v", start, end, wantStart, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeCustomRFC3339(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
start, end, err := usageRange("custom", "2026-08-12T08:00:00Z", "2026-08-12T10:00:00Z", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStart := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC)
|
||||
wantEnd := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
if !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("got %v %v, want %v %v", start, end, wantStart, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeCustomMySQLTime(t *testing.T) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, loc)
|
||||
start, end, err := usageRange("custom", "2026-08-12 00:00:00", "2026-08-12 12:00:00", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStart := time.Date(2026, 8, 12, 0, 0, 0, 0, loc)
|
||||
wantEnd := time.Date(2026, 8, 12, 12, 0, 0, 0, loc)
|
||||
if !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("got %v %v, want %v %v", start.In(loc), end.In(loc), wantStart, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeUnixSeconds(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
from := now.Add(-time.Hour)
|
||||
start, end, err := usageRange("", unixSeconds(from), unixSeconds(now), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start.Unix() != from.Unix() || end.Unix() != now.Unix() {
|
||||
t.Fatalf("got %v %v, want %v %v", start, end, from, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeMissingEnd(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
_, _, err := usageRange("custom", "2026-08-12T00:00:00Z", "", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeEndNotAfterStart(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
_, _, err := usageRange("custom", "2026-08-13T00:00:00Z", "2026-08-13T00:00:00Z", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when end is not after start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageRangeLongWindowAllowed(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 16, 0, 0, 0, time.UTC)
|
||||
start, end, err := usageRange("custom", "2026-01-01T00:00:00Z", "2026-02-02T00:00:00Z", now)
|
||||
if err != nil {
|
||||
t.Fatalf("long window should be allowed: %v", err)
|
||||
}
|
||||
wantStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
wantEnd := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("got %v %v, want %v %v", start, end, wantStart, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFilterStartEndWithoutRange(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/?start=2026-08-12T00:00:00Z&end=2026-08-13T00:00:00Z", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter, err := parseUsageFilter(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.Range != "custom" {
|
||||
t.Fatalf("range = %q, want custom", filter.Range)
|
||||
}
|
||||
wantStart := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
wantEnd := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
|
||||
if !filter.Start.Equal(wantStart) || !filter.End.Equal(wantEnd) {
|
||||
t.Fatalf("got %v %v, want %v %v", filter.Start, filter.End, wantStart, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func unixSeconds(ts time.Time) string {
|
||||
return strconv.FormatInt(ts.Unix(), 10)
|
||||
}
|
||||
121
pkg/aiproxy/handlers/usage_scope_test.go
Normal file
121
pkg/aiproxy/handlers/usage_scope_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/aiproxy/chatlog"
|
||||
)
|
||||
|
||||
func TestRecordMatchesFilterOwnerScope(t *testing.T) {
|
||||
own := chatlog.Record{ProjectID: "proj-a", DomainID: "dom-a"}
|
||||
otherProj := chatlog.Record{ProjectID: "proj-b", DomainID: "dom-a"}
|
||||
otherDom := chatlog.Record{ProjectID: "proj-c", DomainID: "dom-b"}
|
||||
empty := chatlog.Record{}
|
||||
|
||||
projectFilter := usageFilter{Scope: rbacscope.ScopeProject, ProjectID: "proj-a", DomainID: "dom-a"}
|
||||
if !recordMatchesFilter(own, projectFilter) {
|
||||
t.Fatal("project scope should keep own project")
|
||||
}
|
||||
if recordMatchesFilter(otherProj, projectFilter) {
|
||||
t.Fatal("project scope should drop other project")
|
||||
}
|
||||
if recordMatchesFilter(empty, projectFilter) {
|
||||
t.Fatal("project scope should drop empty project_id")
|
||||
}
|
||||
|
||||
domainFilter := usageFilter{Scope: rbacscope.ScopeDomain, ProjectID: "proj-a", DomainID: "dom-a"}
|
||||
if !recordMatchesFilter(own, domainFilter) || !recordMatchesFilter(otherProj, domainFilter) {
|
||||
t.Fatal("domain scope should keep same domain")
|
||||
}
|
||||
if recordMatchesFilter(otherDom, domainFilter) || recordMatchesFilter(empty, domainFilter) {
|
||||
t.Fatal("domain scope should drop other or empty domain")
|
||||
}
|
||||
|
||||
systemFilter := usageFilter{Scope: rbacscope.ScopeSystem}
|
||||
for _, rec := range []chatlog.Record{own, otherProj, otherDom, empty} {
|
||||
if !recordMatchesFilter(rec, systemFilter) {
|
||||
t.Fatalf("system scope should keep %+v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMatchesFilterQueryProjectDomain(t *testing.T) {
|
||||
own := chatlog.Record{ProjectID: "proj-a", DomainID: "dom-a"}
|
||||
otherProj := chatlog.Record{ProjectID: "proj-b", DomainID: "dom-a"}
|
||||
otherDom := chatlog.Record{ProjectID: "proj-c", DomainID: "dom-b"}
|
||||
|
||||
byProject := usageFilter{Scope: rbacscope.ScopeSystem, QueryProject: "proj-a"}
|
||||
if !recordMatchesFilter(own, byProject) {
|
||||
t.Fatal("query project should keep matching project")
|
||||
}
|
||||
if recordMatchesFilter(otherProj, byProject) || recordMatchesFilter(otherDom, byProject) {
|
||||
t.Fatal("query project should drop other projects")
|
||||
}
|
||||
|
||||
byDomain := usageFilter{Scope: rbacscope.ScopeSystem, QueryDomain: "dom-a"}
|
||||
if !recordMatchesFilter(own, byDomain) || !recordMatchesFilter(otherProj, byDomain) {
|
||||
t.Fatal("query domain should keep same domain")
|
||||
}
|
||||
if recordMatchesFilter(otherDom, byDomain) {
|
||||
t.Fatal("query domain should drop other domain")
|
||||
}
|
||||
|
||||
both := usageFilter{Scope: rbacscope.ScopeSystem, QueryProject: "proj-a", QueryDomain: "dom-a"}
|
||||
if !recordMatchesFilter(own, both) {
|
||||
t.Fatal("query project and domain should keep matching record")
|
||||
}
|
||||
if recordMatchesFilter(otherProj, both) || recordMatchesFilter(otherDom, both) {
|
||||
t.Fatal("query project and domain should drop mismatched records")
|
||||
}
|
||||
|
||||
same := usageFilter{Scope: rbacscope.ScopeProject, ProjectID: "proj-a", DomainID: "dom-a", QueryProject: "proj-a"}
|
||||
if !recordMatchesFilter(own, same) {
|
||||
t.Fatal("project scope AND same query project should keep own record")
|
||||
}
|
||||
|
||||
crossed := usageFilter{Scope: rbacscope.ScopeProject, ProjectID: "proj-a", DomainID: "dom-a", QueryProject: "proj-b"}
|
||||
if recordMatchesFilter(own, crossed) || recordMatchesFilter(otherProj, crossed) {
|
||||
t.Fatal("project scope AND other query project should match nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFilterProjectDomain(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/?project=proj-a&domain=dom-a&range=24h", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter, err := parseUsageFilter(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.QueryProject != "proj-a" || filter.QueryDomain != "dom-a" {
|
||||
t.Fatalf("got project=%q domain=%q", filter.QueryProject, filter.QueryDomain)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, "/?tenant_id=tid&project_domain=did&range=24h", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter, err = parseUsageFilter(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.QueryProject != "tid" || filter.QueryDomain != "did" {
|
||||
t.Fatalf("alias got project=%q domain=%q", filter.QueryProject, filter.QueryDomain)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, "/?project_id=pid&domain_id=did2&range=24h", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter, err = parseUsageFilter(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.QueryProject != "pid" || filter.QueryDomain != "did2" {
|
||||
t.Fatalf("id alias got project=%q domain=%q", filter.QueryProject, filter.QueryDomain)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@ type aiKeyHealthState struct {
|
||||
cooldownUntil time.Time
|
||||
}
|
||||
|
||||
// aiKeyHealthSnapshot is a read-only view for error messages (not exported as API).
|
||||
type aiKeyHealthSnapshot struct {
|
||||
score int
|
||||
inCooldown bool
|
||||
remainingSec int
|
||||
}
|
||||
|
||||
var (
|
||||
aiKeyHealthMu sync.RWMutex
|
||||
aiKeyHealth = map[string]*aiKeyHealthState{}
|
||||
@@ -57,6 +64,27 @@ func getAiKeyHealth(keyId string) *aiKeyHealthState {
|
||||
return st
|
||||
}
|
||||
|
||||
// aiKeyHealthInfo returns a read-only snapshot for diagnostics / error text.
|
||||
func aiKeyHealthInfo(keyId string) aiKeyHealthSnapshot {
|
||||
if keyId == "" {
|
||||
return aiKeyHealthSnapshot{score: aiKeyHealthMaxScore}
|
||||
}
|
||||
st := getAiKeyHealth(keyId)
|
||||
now := time.Now()
|
||||
aiKeyHealthMu.RLock()
|
||||
defer aiKeyHealthMu.RUnlock()
|
||||
info := aiKeyHealthSnapshot{score: st.score}
|
||||
if !st.cooldownUntil.IsZero() && now.Before(st.cooldownUntil) {
|
||||
info.inCooldown = true
|
||||
sec := int(st.cooldownUntil.Sub(now).Seconds())
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
info.remainingSec = sec
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// dynamicAiKeyWeightMultiplier returns 0-100 applied to configured ai_key.weight (100 = full weight).
|
||||
func dynamicAiKeyWeightMultiplier(keyId string) int {
|
||||
if keyId == "" {
|
||||
@@ -75,7 +103,12 @@ func dynamicAiKeyWeightMultiplier(keyId string) int {
|
||||
st.score = aiKeyHealthMaxScore / 2
|
||||
}
|
||||
}
|
||||
// score<=0 without an active cooldown would permanently exclude the key;
|
||||
// start a cooldown so it can recover via the path above after the period.
|
||||
if st.score <= 0 {
|
||||
if st.cooldownUntil.IsZero() {
|
||||
st.cooldownUntil = now.Add(aiKeyHealthCooldownPeriod)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if st.score > aiKeyHealthMaxScore {
|
||||
@@ -113,7 +146,8 @@ func RecordAiKeyFailure(keyId string, statusCode int) {
|
||||
if st.score < 0 {
|
||||
st.score = 0
|
||||
}
|
||||
if st.consecutiveFails >= aiKeyHealthCooldownAfter {
|
||||
// Enter cooldown on streak or when score is exhausted (avoids permanent blacklist).
|
||||
if st.consecutiveFails >= aiKeyHealthCooldownAfter || st.score <= 0 {
|
||||
st.cooldownUntil = time.Now().Add(aiKeyHealthCooldownPeriod)
|
||||
st.score = 0
|
||||
}
|
||||
|
||||
256
pkg/aiproxy/models/ai_key_health_test.go
Normal file
256
pkg/aiproxy/models/ai_key_health_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// 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 models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
|
||||
)
|
||||
|
||||
func resetAiKeyHealthForTest() {
|
||||
aiKeyHealthMu.Lock()
|
||||
aiKeyHealth = map[string]*aiKeyHealthState{}
|
||||
aiKeyHealthMu.Unlock()
|
||||
}
|
||||
|
||||
func TestRecordAiKeyFailure_ConsecutiveTriggersCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
const id = "key-consec"
|
||||
for i := 0; i < aiKeyHealthCooldownAfter; i++ {
|
||||
RecordAiKeyFailure(id, 429)
|
||||
}
|
||||
if mul := dynamicAiKeyWeightMultiplier(id); mul != 0 {
|
||||
t.Fatalf("expected multiplier 0 during cooldown, got %d", mul)
|
||||
}
|
||||
info := aiKeyHealthInfo(id)
|
||||
if !info.inCooldown {
|
||||
t.Fatal("expected inCooldown after consecutive failures")
|
||||
}
|
||||
if info.score != 0 {
|
||||
t.Fatalf("expected score 0, got %d", info.score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAiKeyFailure_IntermittentScoreExhaustionEntersCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
const id = "key-intermittent"
|
||||
// fail -25 / success +10 with consecutiveFails reset: score can hit 0 without 3 consecutive fails.
|
||||
for {
|
||||
info := aiKeyHealthInfo(id)
|
||||
if info.score <= 0 || info.inCooldown {
|
||||
break
|
||||
}
|
||||
RecordAiKeyFailure(id, 500)
|
||||
info = aiKeyHealthInfo(id)
|
||||
if info.score <= 0 || info.inCooldown {
|
||||
break
|
||||
}
|
||||
RecordAiKeySuccess(id)
|
||||
if dynamicAiKeyWeightMultiplier(id) <= 0 {
|
||||
t.Fatal("unexpected zero multiplier after success")
|
||||
}
|
||||
}
|
||||
info := aiKeyHealthInfo(id)
|
||||
if !info.inCooldown {
|
||||
t.Fatalf("expected cooldown after score exhaustion, got score=%d inCooldown=%v", info.score, info.inCooldown)
|
||||
}
|
||||
if mul := dynamicAiKeyWeightMultiplier(id); mul != 0 {
|
||||
t.Fatalf("expected multiplier 0 during cooldown, got %d", mul)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicAiKeyWeightMultiplier_RecoversAfterCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
const id = "key-recover"
|
||||
RecordAiKeyFailure(id, 429)
|
||||
RecordAiKeyFailure(id, 429)
|
||||
RecordAiKeyFailure(id, 429)
|
||||
st := getAiKeyHealth(id)
|
||||
aiKeyHealthMu.Lock()
|
||||
st.cooldownUntil = time.Now().Add(-time.Second)
|
||||
aiKeyHealthMu.Unlock()
|
||||
|
||||
mul := dynamicAiKeyWeightMultiplier(id)
|
||||
if mul < aiKeyHealthMaxScore/2 {
|
||||
t.Fatalf("expected recovered multiplier >= %d, got %d", aiKeyHealthMaxScore/2, mul)
|
||||
}
|
||||
info := aiKeyHealthInfo(id)
|
||||
if info.inCooldown {
|
||||
t.Fatal("expected cooldown cleared after expiry")
|
||||
}
|
||||
if info.score < aiKeyHealthMaxScore/2 {
|
||||
t.Fatalf("expected score >= %d, got %d", aiKeyHealthMaxScore/2, info.score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicAiKeyWeightMultiplier_StuckScoreStartsCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
const id = "key-stuck"
|
||||
st := getAiKeyHealth(id)
|
||||
aiKeyHealthMu.Lock()
|
||||
st.score = 0
|
||||
st.cooldownUntil = time.Time{}
|
||||
aiKeyHealthMu.Unlock()
|
||||
|
||||
if mul := dynamicAiKeyWeightMultiplier(id); mul != 0 {
|
||||
t.Fatalf("expected 0, got %d", mul)
|
||||
}
|
||||
info := aiKeyHealthInfo(id)
|
||||
if !info.inCooldown {
|
||||
t.Fatal("expected fallback cooldown for stuck score=0")
|
||||
}
|
||||
|
||||
aiKeyHealthMu.Lock()
|
||||
st.cooldownUntil = time.Now().Add(-time.Second)
|
||||
aiKeyHealthMu.Unlock()
|
||||
mul := dynamicAiKeyWeightMultiplier(id)
|
||||
if mul < aiKeyHealthMaxScore/2 {
|
||||
t.Fatalf("expected recovery after fallback cooldown, got %d", mul)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAiKeySuccess_ClearsCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
const id = "key-success"
|
||||
RecordAiKeyFailure(id, 401)
|
||||
RecordAiKeyFailure(id, 401)
|
||||
RecordAiKeyFailure(id, 401)
|
||||
RecordAiKeySuccess(id)
|
||||
info := aiKeyHealthInfo(id)
|
||||
if info.inCooldown {
|
||||
t.Fatal("success should clear cooldown")
|
||||
}
|
||||
if mul := dynamicAiKeyWeightMultiplier(id); mul <= 0 {
|
||||
t.Fatalf("expected positive multiplier after success, got %d", mul)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiKeySkipReason(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
|
||||
t.Run("already tried", func(t *testing.T) {
|
||||
k := &SAiKey{Secret: "sk-test"}
|
||||
k.Id = "id-tried"
|
||||
k.Name = "tried-key"
|
||||
reason := aiKeySkipReason(k, "deepseek-v4-pro", map[string]bool{"id-tried": true})
|
||||
if !strings.Contains(reason, "already tried") {
|
||||
t.Fatalf("got %q", reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cooldown", func(t *testing.T) {
|
||||
k := &SAiKey{Secret: "sk-test", Weight: 1}
|
||||
k.Id = "id-cd"
|
||||
k.Name = "cd-key"
|
||||
RecordAiKeyFailure(k.Id, 429)
|
||||
RecordAiKeyFailure(k.Id, 429)
|
||||
RecordAiKeyFailure(k.Id, 429)
|
||||
reason := aiKeySkipReason(k, "deepseek-v4-pro", nil)
|
||||
if !strings.Contains(reason, "cooldown") || !strings.Contains(reason, "remaining") {
|
||||
t.Fatalf("got %q", reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("routing", func(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
k := &SAiKey{
|
||||
Secret: "sk-test",
|
||||
Weight: 1,
|
||||
Routing: &api.SAiKeyRouting{
|
||||
AllowedModelKeys: []string{"other-model"},
|
||||
},
|
||||
}
|
||||
k.Id = "id-route"
|
||||
k.Name = "route-key"
|
||||
reason := aiKeySkipReason(k, "deepseek-v4-pro", nil)
|
||||
if !strings.Contains(reason, "model not allowed by routing") {
|
||||
t.Fatalf("got %q", reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty secret", func(t *testing.T) {
|
||||
k := &SAiKey{Secret: " "}
|
||||
k.Name = "empty-key"
|
||||
reason := aiKeySkipReason(k, "m", nil)
|
||||
if !strings.Contains(reason, "empty secret") {
|
||||
t.Fatalf("got %q", reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("usable", func(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
k := &SAiKey{Secret: "sk-ok", Weight: 1}
|
||||
k.Id = "id-ok"
|
||||
k.Name = "ok-key"
|
||||
if reason := aiKeySkipReason(k, "deepseek-v4-pro", nil); reason != "" {
|
||||
t.Fatalf("expected empty reason, got %q", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatAiKeySkipReasons_Truncates(t *testing.T) {
|
||||
reasons := make([]string, maxAiKeySkipReasonsInError+3)
|
||||
for i := range reasons {
|
||||
reasons[i] = "r"
|
||||
}
|
||||
out := formatAiKeySkipReasons(reasons)
|
||||
if !strings.Contains(out, "and 3 more") {
|
||||
t.Fatalf("got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAiKeyWeight_Weight1RecoverableAfterCooldown(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
k := &SAiKey{Secret: "sk-test", Weight: 1}
|
||||
k.Id = "id-recover-w1"
|
||||
k.Name = "recover-w1"
|
||||
for i := 0; i < aiKeyHealthCooldownAfter; i++ {
|
||||
RecordAiKeyFailure(k.Id, 429)
|
||||
}
|
||||
st := getAiKeyHealth(k.Id)
|
||||
aiKeyHealthMu.Lock()
|
||||
st.cooldownUntil = time.Now().Add(-time.Second)
|
||||
aiKeyHealthMu.Unlock()
|
||||
|
||||
if w := effectiveAiKeyWeight(k); w < 1 {
|
||||
t.Fatalf("expected effective weight >= 1 after cooldown recovery, got %d", w)
|
||||
}
|
||||
if reason := aiKeySkipReason(k, "Qwen3.5-4B", nil); reason != "" {
|
||||
t.Fatalf("expected key selectable after cooldown recovery, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAiKeyWeight_Weight1SelectableAfterSingleFailure(t *testing.T) {
|
||||
resetAiKeyHealthForTest()
|
||||
k := &SAiKey{Secret: "sk-test", Weight: 1}
|
||||
k.Id = "id-single-fail-w1"
|
||||
k.Name = "single-fail-w1"
|
||||
RecordAiKeyFailure(k.Id, 429)
|
||||
|
||||
info := aiKeyHealthInfo(k.Id)
|
||||
if info.score != aiKeyHealthMaxScore-aiKeyHealthFailPenalty {
|
||||
t.Fatalf("expected score %d after one failure, got %d", aiKeyHealthMaxScore-aiKeyHealthFailPenalty, info.score)
|
||||
}
|
||||
// Without the floor, 1*75/100 truncates to 0 and permanently excludes the key.
|
||||
if w := effectiveAiKeyWeight(k); w < 1 {
|
||||
t.Fatalf("expected effective weight >= 1 after single failure, got %d", w)
|
||||
}
|
||||
if reason := aiKeySkipReason(k, "Qwen3.5-4B", nil); reason != "" {
|
||||
t.Fatalf("expected key selectable after single failure, got %q", reason)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
)
|
||||
|
||||
const maxAiKeySkipReasonsInError = 8
|
||||
|
||||
func effectiveAiKeyRoutingWeight(r *api.SAiKeyRouting) int {
|
||||
if r == nil || r.Weight <= 0 {
|
||||
return 0
|
||||
@@ -47,6 +50,8 @@ func baseAiKeyWeight(k *SAiKey) int {
|
||||
}
|
||||
|
||||
// effectiveAiKeyWeight returns load-balance weight including dynamic penalty (差 key 降权).
|
||||
// When mul > 0 the result is at least 1 so weight=1 keys are not permanently excluded by
|
||||
// integer truncation (e.g. 1*50/100=0 after cooldown recovery).
|
||||
func effectiveAiKeyWeight(k *SAiKey) int {
|
||||
base := baseAiKeyWeight(k)
|
||||
if k == nil || base <= 0 {
|
||||
@@ -56,7 +61,11 @@ func effectiveAiKeyWeight(k *SAiKey) int {
|
||||
if mul <= 0 {
|
||||
return 0
|
||||
}
|
||||
return base * mul / aiKeyHealthMaxScore
|
||||
w := base * mul / aiKeyHealthMaxScore
|
||||
if w < 1 {
|
||||
return 1
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func aiKeyRoutingAcceptsModel(r *api.SAiKeyRouting, reqModel string) bool {
|
||||
@@ -84,6 +93,58 @@ func aiKeyRoutingAcceptsModel(r *api.SAiKeyRouting, reqModel string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func aiKeyLabel(k *SAiKey) string {
|
||||
if k == nil {
|
||||
return "?"
|
||||
}
|
||||
if n := strings.TrimSpace(k.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if id := strings.TrimSpace(k.Id); id != "" {
|
||||
return id
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// aiKeySkipReason returns why an ai_key cannot be used for modelKey, or "" if usable.
|
||||
func aiKeySkipReason(k *SAiKey, modelKey string, exclude map[string]bool) string {
|
||||
if k == nil {
|
||||
return "?: nil ai_key"
|
||||
}
|
||||
label := aiKeyLabel(k)
|
||||
if strings.TrimSpace(k.GetSecret()) == "" {
|
||||
return label + ": empty secret"
|
||||
}
|
||||
if exclude != nil && exclude[k.Id] {
|
||||
return label + ": already tried"
|
||||
}
|
||||
if baseAiKeyWeight(k) <= 0 {
|
||||
return label + ": weight=0"
|
||||
}
|
||||
if effectiveAiKeyWeight(k) <= 0 {
|
||||
info := aiKeyHealthInfo(k.Id)
|
||||
if info.inCooldown {
|
||||
return fmt.Sprintf("%s: cooldown %ds remaining (health_score=%d)", label, info.remainingSec, info.score)
|
||||
}
|
||||
return fmt.Sprintf("%s: health_score=%d", label, info.score)
|
||||
}
|
||||
if !aiKeyRoutingAcceptsModel(k.Routing, modelKey) {
|
||||
return label + ": model not allowed by routing (allowed_model_keys/blocked_model_keys)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatAiKeySkipReasons(reasons []string) string {
|
||||
if len(reasons) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(reasons) <= maxAiKeySkipReasonsInError {
|
||||
return strings.Join(reasons, "; ")
|
||||
}
|
||||
shown := strings.Join(reasons[:maxAiKeySkipReasonsInError], "; ")
|
||||
return fmt.Sprintf("%s; and %d more", shown, len(reasons)-maxAiKeySkipReasonsInError)
|
||||
}
|
||||
|
||||
func pickWeightedAiKey(candidates []*SAiKey) *SAiKey {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
@@ -145,21 +206,18 @@ func resolveUpstreamAPIKeyExcluding(prov *SAiProvider, modelKey string, exclude
|
||||
|
||||
candidates := make([]*SAiKey, 0, len(keys))
|
||||
hasSecretKey := false
|
||||
skipReasons := make([]string, 0, len(keys))
|
||||
for i := range keys {
|
||||
k := &keys[i]
|
||||
if strings.TrimSpace(k.Secret) == "" {
|
||||
if strings.TrimSpace(k.GetSecret()) == "" {
|
||||
continue
|
||||
}
|
||||
hasSecretKey = true
|
||||
if exclude != nil && exclude[k.Id] {
|
||||
if reason := aiKeySkipReason(k, modelKey, exclude); reason != "" {
|
||||
skipReasons = append(skipReasons, reason)
|
||||
continue
|
||||
}
|
||||
if effectiveAiKeyWeight(k) <= 0 {
|
||||
continue
|
||||
}
|
||||
if aiKeyRoutingAcceptsModel(k.Routing, modelKey) {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
chosen := pickWeightedAiKey(candidates)
|
||||
@@ -167,13 +225,17 @@ func resolveUpstreamAPIKeyExcluding(prov *SAiProvider, modelKey string, exclude
|
||||
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "failed to pick ai_key")
|
||||
}
|
||||
return &resolvedUpstreamAPIKey{
|
||||
Secret: strings.TrimSpace(chosen.Secret),
|
||||
Secret: strings.TrimSpace(chosen.GetSecret()),
|
||||
AiKeyId: chosen.Id,
|
||||
FromRows: true,
|
||||
}, nil
|
||||
}
|
||||
if hasSecretKey {
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available ai_key for catalog model %q (check weight, cooldown, allowed_model_keys)", modelKey)
|
||||
detail := formatAiKeySkipReasons(skipReasons)
|
||||
if detail != "" {
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available ai_key for catalog model %q: %s", modelKey, detail)
|
||||
}
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available ai_key for catalog model %q", modelKey)
|
||||
}
|
||||
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "add an enabled ai_key with secret for this provider")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/sqlchemy"
|
||||
|
||||
"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"
|
||||
@@ -32,12 +33,13 @@ import (
|
||||
|
||||
// SAiKey stores a named upstream API key (or other secret material) for reuse by routing or providers.
|
||||
type SAiKey struct {
|
||||
db.SEnabledStatusStandaloneResourceBase
|
||||
db.SVirtualResourceBase
|
||||
db.SEnabledResourceBase
|
||||
|
||||
// AiProviderId optionally associates this key with a catalog provider row.
|
||||
AiProviderId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
|
||||
// Secret holds raw key material; only privileged scopes should list it.
|
||||
Secret string `width:"4096" charset:"ascii" nullable:"false" create:"required"`
|
||||
Secret string `width:"4096" charset:"ascii" nullable:"false" create:"required" get:"user"`
|
||||
// Weight is used for weighted random load balancing among matching keys (default 1).
|
||||
Weight int `default:"1" nullable:"false" list:"user" create:"optional" update:"user"`
|
||||
// Routing limits which request "model" values may use this key.
|
||||
@@ -45,14 +47,15 @@ type SAiKey struct {
|
||||
}
|
||||
|
||||
type SAiKeyManager struct {
|
||||
db.SEnabledStatusStandaloneResourceBaseManager
|
||||
db.SVirtualResourceBaseManager
|
||||
db.SEnabledResourceBaseManager
|
||||
}
|
||||
|
||||
var AiKeyManager *SAiKeyManager
|
||||
|
||||
func init() {
|
||||
AiKeyManager = &SAiKeyManager{
|
||||
SEnabledStatusStandaloneResourceBaseManager: db.NewEnabledStatusStandaloneResourceBaseManager(
|
||||
SVirtualResourceBaseManager: db.NewVirtualResourceBaseManager(
|
||||
SAiKey{},
|
||||
"ai_keys_tbl",
|
||||
"ai_key",
|
||||
@@ -62,15 +65,84 @@ func init() {
|
||||
AiKeyManager.SetVirtualObject(AiKeyManager)
|
||||
}
|
||||
|
||||
func (manager *SAiKeyManager) InitializeData() error {
|
||||
if err := backfillEmptyTenantId(manager); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateAiKeySecrets()
|
||||
}
|
||||
|
||||
func (k *SAiKey) BeforeInsert() {
|
||||
if len(k.Id) == 0 {
|
||||
k.Id = db.DefaultUUIDGenerator()
|
||||
}
|
||||
if err := k.sealSecret(); err != nil {
|
||||
log.Errorf("ai_key sealSecret: %v", err)
|
||||
}
|
||||
k.SVirtualResourceBase.BeforeInsert()
|
||||
}
|
||||
|
||||
func (k *SAiKey) BeforeUpdate() {
|
||||
if err := k.sealSecret(); err != nil {
|
||||
log.Errorf("ai_key sealSecret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (k *SAiKey) GetSecret() string {
|
||||
return decryptAtRest(k.Id, k.Secret)
|
||||
}
|
||||
|
||||
func (k *SAiKey) sealSecret() error {
|
||||
if len(k.Id) == 0 {
|
||||
k.Id = db.DefaultUUIDGenerator()
|
||||
}
|
||||
plain := strings.TrimSpace(decryptAtRest(k.Id, k.Secret))
|
||||
if plain == "" {
|
||||
return nil
|
||||
}
|
||||
if secretLooksEncrypted(k.Secret) {
|
||||
return nil
|
||||
}
|
||||
enc, err := encryptAtRest(k.Id, plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k.Secret = enc
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateAiKeySecrets() error {
|
||||
keys := make([]SAiKey, 0)
|
||||
q := queryUnprefixedSecret(AiKeyManager.Query(), "secret")
|
||||
if err := q.All(&keys); err != nil {
|
||||
return errors.Wrap(err, "list ai_keys for secret migrate")
|
||||
}
|
||||
for i := range keys {
|
||||
k := &keys[i]
|
||||
k.SetModelManager(AiKeyManager, k)
|
||||
_, err := db.Update(k, func() error {
|
||||
return k.sealSecret()
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "encrypt ai_key %s secret", k.Id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *SAiKeyManager) ListItemFilter(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiKeyListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SEnabledStatusStandaloneResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledStatusStandaloneResourceListInput)
|
||||
q, err := manager.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ListItemFilter")
|
||||
return nil, errors.Wrap(err, "SVirtualResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
q, err = manager.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
if id := strings.TrimSpace(query.AiProviderId); id != "" {
|
||||
q = q.Equals("ai_provider_id", id)
|
||||
@@ -78,6 +150,37 @@ func (manager *SAiKeyManager) ListItemFilter(
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (manager *SAiKeyManager) OrderByExtraFields(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiKeyListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
return manager.SVirtualResourceBaseManager.OrderByExtraFields(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
}
|
||||
|
||||
func (manager *SAiKeyManager) QueryDistinctExtraField(q *sqlchemy.SQuery, field string) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SVirtualResourceBaseManager.QueryDistinctExtraField(q, field)
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
return q, httperrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (k *SAiKey) PerformEnable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformEnableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(k, ctx, userCred, true); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (k *SAiKey) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(k, ctx, userCred, false); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (manager *SAiKeyManager) FetchCustomizeColumns(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
@@ -87,11 +190,14 @@ func (manager *SAiKeyManager) FetchCustomizeColumns(
|
||||
isList bool,
|
||||
) []api.AiKeyDetails {
|
||||
rows := make([]api.AiKeyDetails, len(objs))
|
||||
baseRows := manager.SEnabledStatusStandaloneResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
baseRows := manager.SVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
providerIds := make([]string, len(objs))
|
||||
for i := range objs {
|
||||
rows[i].EnabledStatusStandaloneResourceDetails = baseRows[i]
|
||||
rows[i].VirtualResourceDetails = baseRows[i]
|
||||
k := objs[i].(*SAiKey)
|
||||
plain := k.GetSecret()
|
||||
k.Secret = plain
|
||||
rows[i].Secret = plain
|
||||
providerIds[i] = k.AiProviderId
|
||||
}
|
||||
providerNames, err := db.FetchIdNameMap2(AiProviderManager, providerIds)
|
||||
@@ -113,9 +219,9 @@ func (manager *SAiKeyManager) ValidateCreateData(
|
||||
input api.AiKeyCreateInput,
|
||||
) (api.AiKeyCreateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceCreateInput, err = manager.SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.EnabledStatusStandaloneResourceCreateInput)
|
||||
input.VirtualResourceCreateInput, err = manager.SVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.VirtualResourceCreateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBaseManager.ValidateCreateData")
|
||||
}
|
||||
if input.Weight < 0 {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "weight must be >= 0")
|
||||
@@ -147,9 +253,9 @@ func (k *SAiKey) ValidateUpdateData(
|
||||
input *api.AiKeyUpdateInput,
|
||||
) (*api.AiKeyUpdateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceBaseUpdateInput, err = k.SEnabledStatusStandaloneResourceBase.ValidateUpdateData(ctx, userCred, query, input.EnabledStatusStandaloneResourceBaseUpdateInput)
|
||||
input.VirtualResourceBaseUpdateInput, err = k.SVirtualResourceBase.ValidateUpdateData(ctx, userCred, query, input.VirtualResourceBaseUpdateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBase.ValidateUpdateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBase.ValidateUpdateData")
|
||||
}
|
||||
if input.Weight < 0 {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "weight must be >= 0")
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/sqlchemy"
|
||||
|
||||
"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"
|
||||
@@ -32,7 +33,8 @@ import (
|
||||
|
||||
// SAiModel stores a model catalog row associated with an SAiProvider.
|
||||
type SAiModel struct {
|
||||
db.SEnabledStatusStandaloneResourceBase
|
||||
db.SVirtualResourceBase
|
||||
db.SEnabledResourceBase
|
||||
|
||||
AiProviderId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
|
||||
// ModelKey is the model id sent to the upstream API (e.g. gpt-4o-mini, qwen-turbo).
|
||||
@@ -54,14 +56,15 @@ func (m *SAiModel) VisualActive() bool {
|
||||
}
|
||||
|
||||
type SAiModelManager struct {
|
||||
db.SEnabledStatusStandaloneResourceBaseManager
|
||||
db.SVirtualResourceBaseManager
|
||||
db.SEnabledResourceBaseManager
|
||||
}
|
||||
|
||||
var AiModelManager *SAiModelManager
|
||||
|
||||
func init() {
|
||||
AiModelManager = &SAiModelManager{
|
||||
SEnabledStatusStandaloneResourceBaseManager: db.NewEnabledStatusStandaloneResourceBaseManager(
|
||||
SVirtualResourceBaseManager: db.NewVirtualResourceBaseManager(
|
||||
SAiModel{},
|
||||
"ai_models_tbl",
|
||||
"ai_model",
|
||||
@@ -71,15 +74,23 @@ func init() {
|
||||
AiModelManager.SetVirtualObject(AiModelManager)
|
||||
}
|
||||
|
||||
func (manager *SAiModelManager) InitializeData() error {
|
||||
return backfillEmptyTenantId(manager)
|
||||
}
|
||||
|
||||
func (manager *SAiModelManager) ListItemFilter(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiModelListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SEnabledStatusStandaloneResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledStatusStandaloneResourceListInput)
|
||||
q, err := manager.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ListItemFilter")
|
||||
return nil, errors.Wrap(err, "SVirtualResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
q, err = manager.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
if id := strings.TrimSpace(query.AiProviderId); id != "" {
|
||||
q = q.Equals("ai_provider_id", id)
|
||||
@@ -105,6 +116,37 @@ func (manager *SAiModelManager) ListItemFilter(
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (manager *SAiModelManager) OrderByExtraFields(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiModelListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
return manager.SVirtualResourceBaseManager.OrderByExtraFields(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
}
|
||||
|
||||
func (manager *SAiModelManager) QueryDistinctExtraField(q *sqlchemy.SQuery, field string) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SVirtualResourceBaseManager.QueryDistinctExtraField(q, field)
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
return q, httperrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *SAiModel) PerformEnable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformEnableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(m, ctx, userCred, true); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *SAiModel) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(m, ctx, userCred, false); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func resolveAiRoutingIdForListFilter(ctx context.Context, userCred mcclient.TokenCredential, idOrName string) (string, error) {
|
||||
obj, err := AiRoutingManager.FetchByIdOrName(ctx, userCred, idOrName)
|
||||
if err != nil {
|
||||
@@ -139,12 +181,13 @@ func (manager *SAiModelManager) FetchCustomizeColumns(
|
||||
isList bool,
|
||||
) []api.AiModelDetails {
|
||||
rows := make([]api.AiModelDetails, len(objs))
|
||||
baseRows := manager.SEnabledStatusStandaloneResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
baseRows := manager.SVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
providerIds := make([]string, len(objs))
|
||||
visualProviderIds := make([]string, 0, len(objs))
|
||||
for i := range objs {
|
||||
rows[i].EnabledStatusStandaloneResourceDetails = baseRows[i]
|
||||
rows[i].VirtualResourceDetails = baseRows[i]
|
||||
m := objs[i].(*SAiModel)
|
||||
rows[i].ContextWindow = CatalogContextWindow(m.ModelKey)
|
||||
providerIds[i] = m.AiProviderId
|
||||
if vid := strings.TrimSpace(m.VisualProviderId); vid != "" {
|
||||
visualProviderIds = append(visualProviderIds, vid)
|
||||
@@ -190,9 +233,9 @@ func (manager *SAiModelManager) ValidateCreateData(
|
||||
input api.AiModelCreateInput,
|
||||
) (api.AiModelCreateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceCreateInput, err = manager.SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.EnabledStatusStandaloneResourceCreateInput)
|
||||
input.VirtualResourceCreateInput, err = manager.SVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.VirtualResourceCreateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBaseManager.ValidateCreateData")
|
||||
}
|
||||
|
||||
prov, err := fetchEnabledAiProvider(ctx, userCred, input.AiProviderId)
|
||||
@@ -235,9 +278,9 @@ func (m *SAiModel) ValidateUpdateData(
|
||||
input *api.AiModelUpdateInput,
|
||||
) (*api.AiModelUpdateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceBaseUpdateInput, err = m.SEnabledStatusStandaloneResourceBase.ValidateUpdateData(ctx, userCred, query, input.EnabledStatusStandaloneResourceBaseUpdateInput)
|
||||
input.VirtualResourceBaseUpdateInput, err = m.SVirtualResourceBase.ValidateUpdateData(ctx, userCred, query, input.VirtualResourceBaseUpdateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBase.ValidateUpdateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBase.ValidateUpdateData")
|
||||
}
|
||||
|
||||
providerId := m.AiProviderId
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/sqlchemy"
|
||||
|
||||
"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"
|
||||
@@ -33,7 +34,8 @@ import (
|
||||
|
||||
// SAiProvider stores an LLM provider catalog entry (routing key and OpenAI-compatible config).
|
||||
type SAiProvider struct {
|
||||
db.SEnabledStatusStandaloneResourceBase
|
||||
db.SVirtualResourceBase
|
||||
db.SEnabledResourceBase
|
||||
|
||||
// ProviderKey selects the upstream adapter implementation (e.g. openai, vllm, aliyun).
|
||||
// Multiple ai_provider rows may share the same provider_key with different config.
|
||||
@@ -46,14 +48,15 @@ type SAiProvider struct {
|
||||
}
|
||||
|
||||
type SAiProviderManager struct {
|
||||
db.SEnabledStatusStandaloneResourceBaseManager
|
||||
db.SVirtualResourceBaseManager
|
||||
db.SEnabledResourceBaseManager
|
||||
}
|
||||
|
||||
var AiProviderManager *SAiProviderManager
|
||||
|
||||
func init() {
|
||||
AiProviderManager = &SAiProviderManager{
|
||||
SEnabledStatusStandaloneResourceBaseManager: db.NewEnabledStatusStandaloneResourceBaseManager(
|
||||
SVirtualResourceBaseManager: db.NewVirtualResourceBaseManager(
|
||||
SAiProvider{},
|
||||
"ai_providers_tbl",
|
||||
"ai_provider",
|
||||
@@ -64,7 +67,7 @@ func init() {
|
||||
}
|
||||
|
||||
func (manager *SAiProviderManager) InitializeData() error {
|
||||
return nil
|
||||
return backfillEmptyTenantId(manager)
|
||||
}
|
||||
|
||||
func (manager *SAiProviderManager) ListItemFilter(
|
||||
@@ -73,9 +76,13 @@ func (manager *SAiProviderManager) ListItemFilter(
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiProviderListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SEnabledStatusStandaloneResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledStatusStandaloneResourceListInput)
|
||||
q, err := manager.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ListItemFilter")
|
||||
return nil, errors.Wrap(err, "SVirtualResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
q, err = manager.SEnabledResourceBaseManager.ListItemFilter(ctx, q, userCred, query.EnabledResourceBaseListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SEnabledResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
if key := strings.TrimSpace(query.ProviderKey); key != "" {
|
||||
q = q.Equals("provider_key", key)
|
||||
@@ -89,6 +96,57 @@ func (manager *SAiProviderManager) ListItemFilter(
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (manager *SAiProviderManager) OrderByExtraFields(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiProviderListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
return manager.SVirtualResourceBaseManager.OrderByExtraFields(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
}
|
||||
|
||||
func (manager *SAiProviderManager) QueryDistinctExtraField(q *sqlchemy.SQuery, field string) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SVirtualResourceBaseManager.QueryDistinctExtraField(q, field)
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
return q, httperrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (p *SAiProvider) PerformEnable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformEnableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(p, ctx, userCred, true); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *SAiProvider) PerformDisable(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformDisableInput) (jsonutils.JSONObject, error) {
|
||||
if err := db.EnabledPerformEnable(p, ctx, userCred, false); err != nil {
|
||||
return nil, errors.Wrap(err, "EnabledPerformEnable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// PerformSetModels imports selected catalog models. Existing model_key rows are skipped.
|
||||
func (p *SAiProvider) PerformSetModels(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
query jsonutils.JSONObject,
|
||||
input api.AiProviderSetModelsInput,
|
||||
) (jsonutils.JSONObject, error) {
|
||||
modelKeys, err := normalizeProviderModelKeys(input.ModelKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(modelKeys) == 0 {
|
||||
return nil, errors.Wrap(httperrors.ErrInputParameter, "model_keys is required")
|
||||
}
|
||||
if err := createSelectedProviderModels(ctx, userCred, p.GetOwnerId(), p, modelKeys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (manager *SAiProviderManager) FetchCustomizeColumns(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
@@ -98,9 +156,9 @@ func (manager *SAiProviderManager) FetchCustomizeColumns(
|
||||
isList bool,
|
||||
) []api.AiProviderDetails {
|
||||
rows := make([]api.AiProviderDetails, len(objs))
|
||||
baseRows := manager.SEnabledStatusStandaloneResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
baseRows := manager.SVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
for i := range objs {
|
||||
rows[i].EnabledStatusStandaloneResourceDetails = baseRows[i]
|
||||
rows[i].VirtualResourceDetails = baseRows[i]
|
||||
prov := objs[i].(*SAiProvider)
|
||||
rows[i].LlmDeploymentId = prov.LlmDeploymentId
|
||||
rows[i].LlmId = prov.LlmId
|
||||
@@ -116,9 +174,9 @@ func (manager *SAiProviderManager) ValidateCreateData(
|
||||
input api.AiProviderCreateInput,
|
||||
) (api.AiProviderCreateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceCreateInput, err = manager.SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.EnabledStatusStandaloneResourceCreateInput)
|
||||
input.VirtualResourceCreateInput, err = manager.SVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.VirtualResourceCreateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBaseManager.ValidateCreateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBaseManager.ValidateCreateData")
|
||||
}
|
||||
|
||||
pk, err := validateAiCatalogIdentifier("provider_key", input.ProviderKey, maxAiProviderKeyLen)
|
||||
@@ -184,7 +242,7 @@ func (p *SAiProvider) CustomizeCreate(
|
||||
if err := rejectProviderConfigAPIKeyInJSON(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SEnabledStatusStandaloneResourceBase.CustomizeCreate(ctx, userCred, ownerId, query, data)
|
||||
return p.SVirtualResourceBase.CustomizeCreate(ctx, userCred, ownerId, query, data)
|
||||
}
|
||||
|
||||
func (p *SAiProvider) PostCreate(
|
||||
@@ -194,7 +252,7 @@ func (p *SAiProvider) PostCreate(
|
||||
query jsonutils.JSONObject,
|
||||
data jsonutils.JSONObject,
|
||||
) {
|
||||
p.SEnabledStatusStandaloneResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
||||
p.SVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
||||
|
||||
input := api.AiProviderCreateInput{}
|
||||
if err := data.Unmarshal(&input); err != nil {
|
||||
@@ -255,9 +313,9 @@ func (p *SAiProvider) ValidateUpdateData(
|
||||
input *api.AiProviderUpdateInput,
|
||||
) (*api.AiProviderUpdateInput, error) {
|
||||
var err error
|
||||
input.EnabledStatusStandaloneResourceBaseUpdateInput, err = p.SEnabledStatusStandaloneResourceBase.ValidateUpdateData(ctx, userCred, query, input.EnabledStatusStandaloneResourceBaseUpdateInput)
|
||||
input.VirtualResourceBaseUpdateInput, err = p.SVirtualResourceBase.ValidateUpdateData(ctx, userCred, query, input.VirtualResourceBaseUpdateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SEnabledStatusStandaloneResourceBase.ValidateUpdateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBase.ValidateUpdateData")
|
||||
}
|
||||
|
||||
if pk := strings.TrimSpace(input.ProviderKey); pk != "" {
|
||||
@@ -366,7 +424,7 @@ func (p *SAiProvider) ValidateDeleteCondition(ctx context.Context, info jsonutil
|
||||
}
|
||||
return errors.NewAggregate(errs)
|
||||
}
|
||||
return p.SEnabledStatusStandaloneResourceBase.ValidateDeleteCondition(ctx, info)
|
||||
return p.SVirtualResourceBase.ValidateDeleteCondition(ctx, info)
|
||||
}
|
||||
|
||||
func (p *SAiProvider) CustomizeDelete(
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
)
|
||||
@@ -125,3 +126,34 @@ func TestDeleteAiModelsByProviderIdRequiresProviderId(t *testing.T) {
|
||||
t.Fatal("expected error for empty provider id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderSecretForConnectivityEmptyId(t *testing.T) {
|
||||
_, err := resolveProviderSecretForConnectivity(&SAiProvider{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty provider id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformSetModelsRequiresModelKeys(t *testing.T) {
|
||||
p := &SAiProvider{}
|
||||
_, err := p.PerformSetModels(context.Background(), nil, nil, api.AiProviderSetModelsInput{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty model_keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSelectedProviderModelsNilProvider(t *testing.T) {
|
||||
if err := createSelectedProviderModels(context.Background(), nil, nil, nil, []string{"gpt-4"}); err != nil {
|
||||
t.Fatalf("nil provider should no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProviderModelKeys(t *testing.T) {
|
||||
got, err := normalizeProviderModelKeys([]string{" gpt-4 ", "gpt-4", "deepseek-v4-pro"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %v, want 2 unique keys", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ func (manager *SAiProxyNodeManager) ResourceScope() rbacscope.TRbacScope {
|
||||
return rbacscope.ScopeUser
|
||||
}
|
||||
|
||||
func (node *SAiProxyNode) GetOwnerId() mcclient.IIdentityProvider {
|
||||
return &db.SOwnerId{}
|
||||
}
|
||||
|
||||
func (manager *SAiProxyNodeManager) InitializeData() error {
|
||||
ctx := context.Background()
|
||||
addr, err := AdvertiseAddressFromOptions(nil)
|
||||
@@ -322,6 +326,13 @@ func (node *SAiProxyNode) ValidateUpdateData(
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (node *SAiProxyNode) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSONObject) error {
|
||||
if node.Id == defaultPrimaryAiProxyNodeId {
|
||||
return httperrors.NewForbiddenError("cannot delete primary ai_proxy_node")
|
||||
}
|
||||
return node.SEnabledStatusStandaloneResourceBase.ValidateDeleteCondition(ctx, info)
|
||||
}
|
||||
|
||||
func (node *SAiProxyNode) IsActive() bool {
|
||||
if node.Id == defaultPrimaryAiProxyNodeId {
|
||||
return node.GetEnabled()
|
||||
|
||||
38
pkg/aiproxy/models/ai_proxy_nodes_test.go
Normal file
38
pkg/aiproxy/models/ai_proxy_nodes_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAiProxyNodeValidateDeleteConditionPrimary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
primary := &SAiProxyNode{}
|
||||
primary.Id = defaultPrimaryAiProxyNodeId
|
||||
err := primary.ValidateDeleteCondition(ctx, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting primary ai_proxy_node")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot delete primary ai_proxy_node") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
other := &SAiProxyNode{}
|
||||
other.Id = "node-other"
|
||||
if err := other.ValidateDeleteCondition(ctx, nil); err != nil {
|
||||
t.Fatalf("non-primary should not fail primary-delete rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiProxyNodeGetOwnerIdEmpty(t *testing.T) {
|
||||
node := &SAiProxyNode{}
|
||||
owner := node.GetOwnerId()
|
||||
if owner == nil {
|
||||
t.Fatal("expected empty owner, got nil")
|
||||
}
|
||||
if owner.GetUserId() != "" {
|
||||
t.Fatalf("expected empty user id, got %q", owner.GetUserId())
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import (
|
||||
|
||||
// SAiRoutingModel binds a catalog model (and provider) to an ai_routing with per-entry priority.
|
||||
type SAiRoutingModel struct {
|
||||
db.SStandaloneResourceBase
|
||||
db.SVirtualResourceBase
|
||||
|
||||
AiRoutingId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user" index:"true"`
|
||||
AiProviderId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
|
||||
@@ -55,14 +55,14 @@ type SAiRoutingModel struct {
|
||||
}
|
||||
|
||||
type SAiRoutingModelManager struct {
|
||||
db.SStandaloneResourceBaseManager
|
||||
db.SVirtualResourceBaseManager
|
||||
}
|
||||
|
||||
var AiRoutingModelManager *SAiRoutingModelManager
|
||||
|
||||
func init() {
|
||||
AiRoutingModelManager = &SAiRoutingModelManager{
|
||||
SStandaloneResourceBaseManager: db.NewStandaloneResourceBaseManager(
|
||||
SVirtualResourceBaseManager: db.NewVirtualResourceBaseManager(
|
||||
SAiRoutingModel{},
|
||||
"ai_routing_models_tbl",
|
||||
"ai_routing_model",
|
||||
@@ -72,15 +72,19 @@ func init() {
|
||||
AiRoutingModelManager.SetVirtualObject(AiRoutingModelManager)
|
||||
}
|
||||
|
||||
func (manager *SAiRoutingModelManager) InitializeData() error {
|
||||
return backfillEmptyTenantId(manager)
|
||||
}
|
||||
|
||||
func (manager *SAiRoutingModelManager) ListItemFilter(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiRoutingModelListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SStandaloneResourceBaseManager.ListItemFilter(ctx, q, userCred, query.StandaloneResourceListInput)
|
||||
q, err := manager.SVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "SStandaloneResourceBaseManager.ListItemFilter")
|
||||
return nil, errors.Wrap(err, "SVirtualResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
if id := strings.TrimSpace(query.AiRoutingId); id != "" {
|
||||
q = q.Equals("ai_routing_id", id)
|
||||
@@ -97,6 +101,23 @@ func (manager *SAiRoutingModelManager) ListItemFilter(
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (manager *SAiRoutingModelManager) OrderByExtraFields(
|
||||
ctx context.Context,
|
||||
q *sqlchemy.SQuery,
|
||||
userCred mcclient.TokenCredential,
|
||||
query api.AiRoutingModelListInput,
|
||||
) (*sqlchemy.SQuery, error) {
|
||||
return manager.SVirtualResourceBaseManager.OrderByExtraFields(ctx, q, userCred, query.VirtualResourceListInput)
|
||||
}
|
||||
|
||||
func (manager *SAiRoutingModelManager) QueryDistinctExtraField(q *sqlchemy.SQuery, field string) (*sqlchemy.SQuery, error) {
|
||||
q, err := manager.SVirtualResourceBaseManager.QueryDistinctExtraField(q, field)
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
return q, httperrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (manager *SAiRoutingModelManager) FetchCustomizeColumns(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
@@ -106,9 +127,9 @@ func (manager *SAiRoutingModelManager) FetchCustomizeColumns(
|
||||
isList bool,
|
||||
) []api.AiRoutingModelDetails {
|
||||
rows := make([]api.AiRoutingModelDetails, len(objs))
|
||||
baseRows := manager.SStandaloneResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
baseRows := manager.SVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
|
||||
for i := range objs {
|
||||
rows[i].StandaloneResourceDetails = baseRows[i]
|
||||
rows[i].VirtualResourceDetails = baseRows[i]
|
||||
rm := objs[i].(*SAiRoutingModel)
|
||||
rows[i].Id = rm.Id
|
||||
rows[i].Name = rm.Name
|
||||
@@ -130,9 +151,9 @@ func (manager *SAiRoutingModelManager) ValidateCreateData(
|
||||
input api.AiRoutingModelCreateInput,
|
||||
) (api.AiRoutingModelCreateInput, error) {
|
||||
var err error
|
||||
input.StandaloneResourceCreateInput, err = manager.SStandaloneResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.StandaloneResourceCreateInput)
|
||||
input.VirtualResourceCreateInput, err = manager.SVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.VirtualResourceCreateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SStandaloneResourceBaseManager.ValidateCreateData")
|
||||
return input, errors.Wrap(err, "SVirtualResourceBaseManager.ValidateCreateData")
|
||||
}
|
||||
|
||||
routingId := strings.TrimSpace(input.AiRoutingId)
|
||||
|
||||
@@ -44,8 +44,10 @@ type SAiVirtualKey struct {
|
||||
|
||||
// OwnerId is the user that owns this virtual key within the project.
|
||||
OwnerId string `width:"128" charset:"ascii" index:"true" list:"user" nullable:"false" create:"optional" update:"user"`
|
||||
// VirtualKey is the opaque key id or prefix presented to clients (not the upstream provider secret).
|
||||
VirtualKey string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"optional" update:"user"`
|
||||
// VirtualKey stores the client token encrypted at rest with the row id.
|
||||
VirtualKey string `width:"512" charset:"ascii" nullable:"false" create:"optional" update:"user" get:"user"`
|
||||
// VirtualKeyHash is SHA256 of the plaintext token for auth lookup.
|
||||
VirtualKeyHash string `width:"64" charset:"ascii" nullable:"true" unique:"true"`
|
||||
// Limits constrains allowed providers, per-request max_tokens, and request rate.
|
||||
Limits *api.SAiVirtualKeyLimits `length:"medium" charset:"utf8" list:"user" create:"optional" update:"user"`
|
||||
}
|
||||
@@ -69,6 +71,73 @@ func init() {
|
||||
AiVirtualKeyManager.SetVirtualObject(AiVirtualKeyManager)
|
||||
}
|
||||
|
||||
func (manager *SAiVirtualKeyManager) InitializeData() error {
|
||||
return migrateAiVirtualKeys()
|
||||
}
|
||||
|
||||
func (m *SAiVirtualKey) BeforeInsert() {
|
||||
if len(m.Id) == 0 {
|
||||
m.Id = db.DefaultUUIDGenerator()
|
||||
}
|
||||
if err := m.sealVirtualKey(); err != nil {
|
||||
log.Errorf("ai_virtual_key sealVirtualKey: %v", err)
|
||||
}
|
||||
m.SVirtualResourceBase.BeforeInsert()
|
||||
}
|
||||
|
||||
func (m *SAiVirtualKey) BeforeUpdate() {
|
||||
if err := m.sealVirtualKey(); err != nil {
|
||||
log.Errorf("ai_virtual_key sealVirtualKey: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SAiVirtualKey) GetVirtualKey() string {
|
||||
return decryptAtRest(m.Id, m.VirtualKey)
|
||||
}
|
||||
|
||||
func (m *SAiVirtualKey) sealVirtualKey() error {
|
||||
if len(m.Id) == 0 {
|
||||
m.Id = db.DefaultUUIDGenerator()
|
||||
}
|
||||
plain := strings.TrimSpace(decryptAtRest(m.Id, m.VirtualKey))
|
||||
if plain == "" {
|
||||
return nil
|
||||
}
|
||||
m.VirtualKeyHash = virtualKeyDigest(plain)
|
||||
if secretLooksEncrypted(m.VirtualKey) {
|
||||
return nil
|
||||
}
|
||||
enc, err := encryptAtRest(m.Id, plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.VirtualKey = enc
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateAiVirtualKeys() error {
|
||||
keys := make([]SAiVirtualKey, 0)
|
||||
q := AiVirtualKeyManager.Query().IsNotEmpty("virtual_key")
|
||||
q = q.Filter(sqlchemy.OR(
|
||||
sqlchemy.NOT(sqlchemy.Startswith(q.Field("virtual_key"), atRestPrefix)),
|
||||
sqlchemy.IsNullOrEmpty(q.Field("virtual_key_hash")),
|
||||
))
|
||||
if err := q.All(&keys); err != nil {
|
||||
return errors.Wrap(err, "list ai_virtual_keys for migrate")
|
||||
}
|
||||
for i := range keys {
|
||||
m := &keys[i]
|
||||
m.SetModelManager(AiVirtualKeyManager, m)
|
||||
_, err := db.Update(m, func() error {
|
||||
return m.sealVirtualKey()
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "encrypt ai_virtual_key %s", m.Id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SAiVirtualKey) GetOwnerId() mcclient.IIdentityProvider {
|
||||
owner := db.SOwnerId{
|
||||
UserId: m.OwnerId,
|
||||
@@ -126,7 +195,7 @@ func (manager *SAiVirtualKeyManager) ListItemFilter(
|
||||
return nil, errors.Wrap(err, "SEnabledResourceBaseManager.ListItemFilter")
|
||||
}
|
||||
if v := strings.TrimSpace(query.VirtualKey); v != "" {
|
||||
q = q.Equals("virtual_key", v)
|
||||
q = q.Equals("virtual_key_hash", virtualKeyDigest(v))
|
||||
}
|
||||
userId := strings.TrimSpace(query.UserId)
|
||||
if userId != "" {
|
||||
@@ -175,6 +244,9 @@ func (manager *SAiVirtualKeyManager) FetchCustomizeColumns(
|
||||
for i := range objs {
|
||||
rows[i].VirtualResourceDetails = virtRows[i]
|
||||
vk := objs[i].(*SAiVirtualKey)
|
||||
plain := vk.GetVirtualKey()
|
||||
vk.VirtualKey = plain
|
||||
rows[i].VirtualKey = plain
|
||||
if strings.TrimSpace(vk.OwnerId) != "" {
|
||||
userIds[i] = vk.OwnerId
|
||||
}
|
||||
@@ -291,7 +363,11 @@ func validateAiVirtualKeyLimits(ctx context.Context, userCred mcclient.TokenCred
|
||||
}
|
||||
|
||||
func aiVirtualKeyExists(virtualKey string) (bool, error) {
|
||||
cnt, err := AiVirtualKeyManager.Query().Equals("virtual_key", virtualKey).CountWithError()
|
||||
digest := virtualKeyDigest(virtualKey)
|
||||
if digest == "" {
|
||||
return false, nil
|
||||
}
|
||||
cnt, err := AiVirtualKeyManager.Query().Equals("virtual_key_hash", digest).CountWithError()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "count ai_virtual_key")
|
||||
}
|
||||
|
||||
@@ -20,11 +20,19 @@ import (
|
||||
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
|
||||
)
|
||||
|
||||
const (
|
||||
catalogContextWindow1M = 1_000_000
|
||||
catalogContextWindow1050K = 1_050_000
|
||||
catalogContextWindow2M = 2_000_000
|
||||
)
|
||||
|
||||
// catalogSeedModel is a known upstream model id for built-in provider_key values.
|
||||
// ModelKey is the id sent to the upstream API (no "provider/" prefix).
|
||||
// ContextWindow is 0 when unknown.
|
||||
type catalogSeedModel struct {
|
||||
ModelKey string
|
||||
Description string
|
||||
ModelKey string
|
||||
Description string
|
||||
ContextWindow int
|
||||
}
|
||||
|
||||
// catalogSeedModelsForProvider returns known public model ids for a provider_key.
|
||||
@@ -34,8 +42,8 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
switch providerKey {
|
||||
case api.ProviderKeyAnthropic:
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "claude-opus-4-20250514", Description: "Anthropic Claude Opus 4"},
|
||||
{ModelKey: "claude-sonnet-4-20250514", Description: "Anthropic Claude Sonnet 4"},
|
||||
{ModelKey: "claude-opus-4-20250514", Description: "Anthropic Claude Opus 4", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "claude-sonnet-4-20250514", Description: "Anthropic Claude Sonnet 4", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "claude-3-7-sonnet-20250219", Description: "Anthropic Claude 3.7 Sonnet"},
|
||||
{ModelKey: "claude-3-5-sonnet-20241022", Description: "Anthropic Claude 3.5 Sonnet"},
|
||||
{ModelKey: "claude-3-5-haiku-20241022", Description: "Anthropic Claude 3.5 Haiku"},
|
||||
@@ -85,8 +93,8 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
// }
|
||||
case api.ProviderKeyDeepseek:
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "deepseek-v4-flash", Description: "DeepSeek-V4-Flash; 1M context; high concurrency (2500); cost-efficient default"},
|
||||
{ModelKey: "deepseek-v4-pro", Description: "DeepSeek-V4-Pro; 1M context; frontier reasoning/coding/agents (500 concurrency)"},
|
||||
{ModelKey: "deepseek-v4-flash", Description: "DeepSeek-V4-Flash; 1M context; high concurrency (2500); cost-efficient default", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "deepseek-v4-pro", Description: "DeepSeek-V4-Pro; 1M context; frontier reasoning/coding/agents (500 concurrency)", ContextWindow: catalogContextWindow1M},
|
||||
}
|
||||
// disabled: uncommon provider
|
||||
// case api.ProviderKeyElevenlabs:
|
||||
@@ -106,11 +114,11 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
// }
|
||||
case api.ProviderKeyGemini:
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "gemini-2.0-flash", Description: "Google Gemini 2.0 Flash"},
|
||||
{ModelKey: "gemini-2.0-flash-lite", Description: "Google Gemini 2.0 Flash-Lite"},
|
||||
{ModelKey: "gemini-1.5-pro", Description: "Google Gemini 1.5 Pro"},
|
||||
{ModelKey: "gemini-1.5-flash", Description: "Google Gemini 1.5 Flash"},
|
||||
{ModelKey: "gemini-1.5-flash-8b", Description: "Google Gemini 1.5 Flash 8B"},
|
||||
{ModelKey: "gemini-2.0-flash", Description: "Google Gemini 2.0 Flash", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gemini-2.0-flash-lite", Description: "Google Gemini 2.0 Flash-Lite", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gemini-1.5-pro", Description: "Google Gemini 1.5 Pro", ContextWindow: catalogContextWindow2M},
|
||||
{ModelKey: "gemini-1.5-flash", Description: "Google Gemini 1.5 Flash", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gemini-1.5-flash-8b", Description: "Google Gemini 1.5 Flash 8B", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gemini-embedding-001", Description: "Google Gemini Embedding 001"},
|
||||
}
|
||||
case api.ProviderKeyGroq:
|
||||
@@ -175,9 +183,9 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
{ModelKey: "gpt-5.2", Description: "OpenAI GPT-5.2"},
|
||||
{ModelKey: "gpt-5.2-pro", Description: "OpenAI GPT-5.2 pro"},
|
||||
{ModelKey: "gpt-5.2-codex", Description: "OpenAI GPT-5.2 Codex"},
|
||||
{ModelKey: "gpt-4.1", Description: "OpenAI GPT-4.1"},
|
||||
{ModelKey: "gpt-4.1-mini", Description: "OpenAI GPT-4.1 mini"},
|
||||
{ModelKey: "gpt-4.1-nano", Description: "OpenAI GPT-4.1 nano"},
|
||||
{ModelKey: "gpt-4.1", Description: "OpenAI GPT-4.1", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gpt-4.1-mini", Description: "OpenAI GPT-4.1 mini", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gpt-4.1-nano", Description: "OpenAI GPT-4.1 nano", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "gpt-4o", Description: "OpenAI GPT-4o"},
|
||||
{ModelKey: "gpt-4o-mini", Description: "OpenAI GPT-4o mini"},
|
||||
{ModelKey: "chatgpt-4o-latest", Description: "OpenAI ChatGPT-4o latest"},
|
||||
@@ -200,7 +208,7 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
{ModelKey: "openai/gpt-4o-mini", Description: "OpenRouter OpenAI GPT-4o mini"},
|
||||
{ModelKey: "anthropic/claude-3.5-sonnet", Description: "OpenRouter Claude 3.5 Sonnet"},
|
||||
{ModelKey: "anthropic/claude-3.5-haiku", Description: "OpenRouter Claude 3.5 Haiku"},
|
||||
{ModelKey: "google/gemini-2.0-flash-001", Description: "OpenRouter Gemini 2.0 Flash"},
|
||||
{ModelKey: "google/gemini-2.0-flash-001", Description: "OpenRouter Gemini 2.0 Flash", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "meta-llama/llama-3.3-70b-instruct", Description: "OpenRouter Llama 3.3 70B Instruct"},
|
||||
{ModelKey: "mistralai/mistral-large", Description: "OpenRouter Mistral Large"},
|
||||
}
|
||||
@@ -306,9 +314,9 @@ func baiduErnieSeedModels() []catalogSeedModel {
|
||||
|
||||
func xiaomiMimoSeedModels() []catalogSeedModel {
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "mimo-v2.5-pro", Description: "Xiaomi MiMo 2.5 Pro (flagship text)"},
|
||||
{ModelKey: "mimo-v2.5-pro", Description: "Xiaomi MiMo 2.5 Pro (flagship text)", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "mimo-v2-pro", Description: "Xiaomi MiMo 2 Pro"},
|
||||
{ModelKey: "mimo-v2.5", Description: "Xiaomi MiMo 2.5 (multimodal text)"},
|
||||
{ModelKey: "mimo-v2.5", Description: "Xiaomi MiMo 2.5 (multimodal text)", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "mimo-v2-omni", Description: "Xiaomi MiMo 2 Omni (multimodal)"},
|
||||
{ModelKey: "mimo-v2-flash", Description: "Xiaomi MiMo 2 Flash (fast)"},
|
||||
}
|
||||
@@ -327,6 +335,7 @@ var catalogSeedProviderKeys = []string{
|
||||
api.ProviderKeyOpenrouter,
|
||||
api.ProviderKeyXiaomi,
|
||||
api.ProviderKeyMoonshot,
|
||||
api.ProviderKeyZhipu,
|
||||
}
|
||||
|
||||
// CatalogSeedDescription returns a known description for modelKey from built-in seed catalogs.
|
||||
@@ -345,6 +354,77 @@ func CatalogSeedDescription(modelKey string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extraCatalogContextWindows covers official 1M+ models that are not yet in seed lists.
|
||||
// Keys are lowercase model ids (and last-path-segment aliases).
|
||||
var extraCatalogContextWindows = map[string]int{
|
||||
"deepseek-chat": catalogContextWindow1M,
|
||||
"deepseek-reasoner": catalogContextWindow1M,
|
||||
"glm-5.3": catalogContextWindow1M,
|
||||
"glm-5.3-flash": catalogContextWindow1M,
|
||||
"kimi-k3": catalogContextWindow1M,
|
||||
"gpt-5.4": catalogContextWindow1050K,
|
||||
"gpt-5.4-pro": catalogContextWindow1050K,
|
||||
"gpt-5.6": catalogContextWindow1050K,
|
||||
"gpt-5.6-sol": catalogContextWindow1050K,
|
||||
"gpt-5.6-terra": catalogContextWindow1050K,
|
||||
"gpt-5.6-luna": catalogContextWindow1050K,
|
||||
"gemini-2.5-pro": catalogContextWindow1M,
|
||||
"gemini-2.5-flash": catalogContextWindow1M,
|
||||
"gemini-2.5-flash-lite": catalogContextWindow1M,
|
||||
"gemini-2.0-flash-001": catalogContextWindow1M,
|
||||
"claude-opus-5": catalogContextWindow1M,
|
||||
"claude-sonnet-5": catalogContextWindow1M,
|
||||
"claude-fable-5": catalogContextWindow1M,
|
||||
"claude-fable-5-1": catalogContextWindow1M,
|
||||
}
|
||||
|
||||
func catalogModelKeyLeaf(modelKey string) string {
|
||||
modelKey = strings.TrimSpace(modelKey)
|
||||
if i := strings.LastIndex(modelKey, "/"); i >= 0 {
|
||||
return modelKey[i+1:]
|
||||
}
|
||||
return modelKey
|
||||
}
|
||||
|
||||
func extraCatalogContextWindow(modelKey string) int {
|
||||
if n, ok := extraCatalogContextWindows[strings.ToLower(strings.TrimSpace(modelKey))]; ok {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CatalogContextWindow returns the known context window for modelKey from the
|
||||
// built-in catalog (seed rows plus extra official ids not yet seeded). 0 means unknown.
|
||||
func CatalogContextWindow(modelKey string) int {
|
||||
modelKey = strings.TrimSpace(modelKey)
|
||||
if modelKey == "" {
|
||||
return 0
|
||||
}
|
||||
if n := extraCatalogContextWindow(modelKey); n > 0 {
|
||||
return n
|
||||
}
|
||||
leaf := catalogModelKeyLeaf(modelKey)
|
||||
if leaf != modelKey {
|
||||
if n := extraCatalogContextWindow(leaf); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
for _, providerKey := range catalogSeedProviderKeys {
|
||||
for _, item := range catalogSeedModelsForProvider(providerKey) {
|
||||
if item.ContextWindow <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(item.ModelKey, modelKey) {
|
||||
return item.ContextWindow
|
||||
}
|
||||
if strings.EqualFold(catalogModelKeyLeaf(item.ModelKey), leaf) {
|
||||
return item.ContextWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func moonshotKimiSeedModels() []catalogSeedModel {
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "kimi-k2.7-code", Description: "Moonshot Kimi K2.7 Code"},
|
||||
@@ -363,7 +443,7 @@ func moonshotKimiSeedModels() []catalogSeedModel {
|
||||
|
||||
func zhipuGLMSeedModels() []catalogSeedModel {
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "glm-5.2", Description: "Z.AI GLM-5.2 flagship; 1M context; long-horizon agents"},
|
||||
{ModelKey: "glm-5.2", Description: "Z.AI GLM-5.2 flagship; 1M context; long-horizon agents", ContextWindow: catalogContextWindow1M},
|
||||
{ModelKey: "glm-5.1", Description: "Z.AI GLM-5.1; long-horizon tasks"},
|
||||
{ModelKey: "glm-5-turbo", Description: "Z.AI GLM-5-Turbo; cost-efficient"},
|
||||
{ModelKey: "glm-4.7", Description: "Z.AI GLM-4.7; general chat and coding"},
|
||||
|
||||
@@ -35,3 +35,40 @@ func TestCatalogSeedModelsSkippedForSelfHostedProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogContextWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
modelKey string
|
||||
want int
|
||||
}{
|
||||
{modelKey: "deepseek-v4-pro", want: 1_000_000},
|
||||
{modelKey: "deepseek-v4-flash", want: 1_000_000},
|
||||
{modelKey: "deepseek-chat", want: 1_000_000},
|
||||
{modelKey: "glm-5.2", want: 1_000_000},
|
||||
{modelKey: "glm-5.3", want: 1_000_000},
|
||||
{modelKey: "kimi-k3", want: 1_000_000},
|
||||
{modelKey: "mimo-v2.5", want: 1_000_000},
|
||||
{modelKey: "gpt-4.1", want: 1_000_000},
|
||||
{modelKey: "gpt-5.6", want: 1_050_000},
|
||||
{modelKey: "gemini-2.0-flash", want: 1_000_000},
|
||||
{modelKey: "gemini-1.5-pro", want: 2_000_000},
|
||||
{modelKey: "claude-opus-4-20250514", want: 1_000_000},
|
||||
{modelKey: "claude-sonnet-5", want: 1_000_000},
|
||||
{modelKey: "glm-5.1", want: 0},
|
||||
{modelKey: "gpt-5.2", want: 0},
|
||||
{modelKey: "kimi-k2.6", want: 0},
|
||||
{modelKey: "", want: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := CatalogContextWindow(tc.modelKey)
|
||||
if tc.want >= 1_000_000 {
|
||||
if got != tc.want {
|
||||
t.Errorf("CatalogContextWindow(%q) = %d, want %d", tc.modelKey, got, tc.want)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got >= 1_000_000 {
|
||||
t.Errorf("CatalogContextWindow(%q) = %d, want 0 or < 1M", tc.modelKey, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func loadEnabledVirtualKey(virtualKey string) (*SAiVirtualKey, error) {
|
||||
return nil, errors.Wrap(httperrors.ErrInputParameter, "missing virtual key (Authorization: Bearer <vk> or X-Ai-Virtual-Key)")
|
||||
}
|
||||
vk := SAiVirtualKey{}
|
||||
qvk := AiVirtualKeyManager.Query().Equals("virtual_key", virtualKey).Equals("enabled", true)
|
||||
qvk := AiVirtualKeyManager.Query().Equals("virtual_key_hash", virtualKeyDigest(virtualKey)).Equals("enabled", true)
|
||||
err := qvk.First(&vk)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -194,6 +194,54 @@ func providerUpstreamModels(modelKeys []string) []api.AiProviderUpstreamModel {
|
||||
return out
|
||||
}
|
||||
|
||||
func connectivityTestOutput(modelKeys []string, fromCatalog bool) api.AiProviderTestConnectivityOutput {
|
||||
out := api.AiProviderTestConnectivityOutput{Ok: true}
|
||||
if fromCatalog {
|
||||
out.Message = "connectivity test passed (catalog models)"
|
||||
out.ModelsSource = api.AiProviderModelsSourceCatalog
|
||||
} else {
|
||||
out.Message = "connectivity test passed"
|
||||
out.ModelsSource = api.AiProviderModelsSourceUpstream
|
||||
}
|
||||
out.Models = providerUpstreamModels(modelKeys)
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveProviderSecretForConnectivity picks an enabled ai_key secret without model-key filtering.
|
||||
func resolveProviderSecretForConnectivity(prov *SAiProvider) (string, error) {
|
||||
if prov == nil {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "ai_provider is nil")
|
||||
}
|
||||
pid := strings.TrimSpace(prov.Id)
|
||||
if pid == "" {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "ai_provider id is empty")
|
||||
}
|
||||
keys := make([]SAiKey, 0, 32)
|
||||
q := AiKeyManager.Query().Equals("ai_provider_id", pid).Equals("enabled", true)
|
||||
if err := q.All(&keys); err != nil {
|
||||
return "", errors.Wrap(err, "list ai_key for provider")
|
||||
}
|
||||
candidates := make([]*SAiKey, 0, len(keys))
|
||||
for i := range keys {
|
||||
k := &keys[i]
|
||||
if strings.TrimSpace(k.GetSecret()) == "" {
|
||||
continue
|
||||
}
|
||||
if baseAiKeyWeight(k) <= 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "add an enabled ai_key with secret for this provider")
|
||||
}
|
||||
chosen := pickWeightedAiKey(candidates)
|
||||
if chosen == nil {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "failed to pick ai_key")
|
||||
}
|
||||
return strings.TrimSpace(chosen.GetSecret()), nil
|
||||
}
|
||||
|
||||
// PerformTestConnectivity probes upstream list-models without persisting an ai_provider row.
|
||||
func (manager *SAiProviderManager) PerformTestConnectivity(
|
||||
ctx context.Context,
|
||||
@@ -206,14 +254,24 @@ func (manager *SAiProviderManager) PerformTestConnectivity(
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Ok = true
|
||||
if fromCatalog {
|
||||
out.Message = "connectivity test passed (catalog models)"
|
||||
out.ModelsSource = api.AiProviderModelsSourceCatalog
|
||||
} else {
|
||||
out.Message = "connectivity test passed"
|
||||
out.ModelsSource = api.AiProviderModelsSourceUpstream
|
||||
}
|
||||
out.Models = providerUpstreamModels(modelKeys)
|
||||
return out, nil
|
||||
return connectivityTestOutput(modelKeys, fromCatalog), nil
|
||||
}
|
||||
|
||||
// PerformTestConnectivity lists upstream models using a stored enabled ai_key.
|
||||
func (p *SAiProvider) PerformTestConnectivity(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
query jsonutils.JSONObject,
|
||||
input api.AiProviderInstanceTestConnectivityInput,
|
||||
) (api.AiProviderTestConnectivityOutput, error) {
|
||||
out := api.AiProviderTestConnectivityOutput{}
|
||||
secret, err := resolveProviderSecretForConnectivity(p)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
modelKeys, fromCatalog, err := listProviderModels(ctx, p.ProviderKey, secret, p.Config, providerTestConnectivityTimeout)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return connectivityTestOutput(modelKeys, fromCatalog), nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user