Compare commits

...

144 Commits

Author SHA1 Message Date
Zexi Li
45fc5cf0d0 feat(llm): route MCP agent via aiproxy virtual key and routing id (#25665)
Support X-Ai-Routing-Id to pin ai_routing, treat empty model_pattern as non-wildcard, and wire MCP agent to aiproxy credentials instead of direct LLM api_key.
2026-09-11 10:13:45 +08:00
Jian Qiu
64801c02c1 fix: classic nic dhcp default gateway revisit (#25666)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-10 21:06:24 +08:00
Jian Qiu
8fdbce23d6 fix: keystone-role-assignments-list-scope (#25658)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-10 17:25:11 +08:00
Jian Qiu
82e3a751b2 fix(cloudid): omit clouduser secret from list and get responses (#25661)
Return console login details from the login-info action; skip secret in resource JSON and logs.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 17:24:22 +08:00
Jian Qiu
fd4f251d7a fix: keystone idp join project roles check (#25655)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-10 17:06:30 +08:00
Jian Qiu
d6dcae5da9 fix(llm): validate mcp agent server url and session endpoint (#25650)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:29:41 +08:00
Jian Qiu
f18dd74131 fix(llm): authenticate llm router agent route requests (#25648)
Fetch the agent with the caller session and check perform permission.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 14:44:38 +08:00
Jian Qiu
730fa194a5 fix(llm): require get permission when resolving llm_id (#25646)
Fetch the referenced LLM with the caller session before filling access URL fields.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 14:15:18 +08:00
Jian Qiu
4af0712a1b fix(identity): validate rolepolicy create and bind-role requests (#25643)
Require role_id and reuse the role add-policy checks when creating a binding.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 13:10:04 +08:00
屈轩
0b04770576 fix(region): convert kvm prefer storage (#25640) 2026-09-10 12:21:58 +08:00
Jian Qiu
94cff9d81e fix(identity): check assume login with system scope and target roles (#25636)
Keep the caller session when authorizing assume and reuse join-project role checks.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 12:14:23 +08:00
Jian Qiu
c47b7b3e06 fix(image): reject images that declare a backing file (#25634)
Treat a non-empty backing filename as unsupported on upload, convert, probe, and ceph cache.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:54:50 +08:00
Jian Qiu
83fd5378f5 fix: allow host networks no gateway (#25624)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-10 01:32:59 +08:00
Jian Qiu
6c441f09d9 fix: classic net distribute default route for default nic (#25616)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-09 22:28:38 +08:00
Zexi Li
8560cd8450 fix(region): include disabled hosts in isolated device usage stats (#25620)
Keep GPU/device usages consistent with hosts inventory; scheduling still uses enabled hosts only.
2026-09-09 22:27:46 +08:00
Jian Qiu
4aa916cc23 fix(container): validate host bind paths and create host-path files with argv (#25604)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 20:29:23 +08:00
wanyaoqi
c3da18df12 fix(region): set force stop default timeout 30s (#25613) 2026-09-09 20:28:14 +08:00
Zexi Li
884de1bc51 fix(llm): skip stale container status during pod transitions (#25610)
Watch container events and defer LLM status updates while the pod is starting/stopping, so exited leftovers are not treated as start_fail.
2026-09-09 16:51:57 +08:00
Zexi Li
cb876f036a fix(host): use assigned nvidia GPU memory for pod metrics (#25607)
Derive mem total from isolated-device quotas when available, and harden
losetup ListDevices against empty --json output.
2026-09-09 12:01:31 +08:00
Jian Qiu
4979e0f7b9 fix(guestfs): keep guest file deploy inside the mounted filesystem (#25601)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 23:47:10 +08:00
Jian Qiu
41d79c09d6 fix(identity): check SAML assertion issuer, audience, and optional signature (#25598)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 20:04:47 +08:00
屈轩
3597116f15 fix(region): support renew kvm vm (#25595) 2026-09-08 15:52:17 +08:00
Jian Qiu
c1e73206c8 fix(cloudcommon): fall back list query scope to the resource default view (#25589)
When admin or scope query parameters do not map to a concrete view, use the resource's own scope and keep owner filters applied.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:52:35 +08:00
wanyaoqi
a65fb1b852 fix(host): start new goroutine to stop guest (#25585) 2026-09-08 14:09:14 +08:00
Zexi Li
038e530ebc feat(llm): support force restart for LLM and deployments (#25582)
Allow force restart when instances are failed, protect restarting status
during sync, refresh GPU memory from deployment, and tolerate crash-loop
while waiting for service ready.
2026-09-07 21:13:46 +08:00
屈轩
27d6c94d7d fix(mcp-server): upgrade mcp lib (#25579) 2026-09-07 14:34:31 +08:00
Zexi Li
4ea6581102 feat(llm): allow disabling pod/container cgroup CPU and memory limits (#25576)
Inference SKUs default to off; SKU and CLI expose enable flags so hosts can skip CFS quota and memory hard limits when needed.
2026-09-07 13:04:13 +08:00
Jian Qiu
3e77511214 fix(apigateway): authenticate csrf resource requests with the caller session (#25568)
Align the csrf resource handler with other API resource handlers by requiring a token and forwarding with the caller's own session.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:13:32 +08:00
屈轩
4bf0b18ad0 fix(region): vendor update (#25572) 2026-09-07 10:28:48 +08:00
Jian Qiu
1dc0e3b5ec fix: module not found return 404 (#25560)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-04 19:55:06 +08:00
Jian Qiu
df217c1be2 fix: verify test revisit (#25564)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-04 19:40:01 +08:00
屈轩
adf05b2988 fix(region): esxi v2v prefer storage (#25556) 2026-09-04 17:33:39 +08:00
Jian Qiu
3c35e76604 fix(webconsole): authenticate sftp endpoints and check session owner (#25551)
The sftp list/download/upload endpoints were not protected by
authentication, only by the UUID4 session id. Anyone who learned the
session id (it appears in URLs and logs) could list, download and
upload files of the VM over the victim's active SSH/SFTP channel.

Wrap all three endpoints with auth.Authenticate and record the owner
of each sftp session at registration, so only the user who opened the
session can use it.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 13:45:50 +08:00
Jian Qiu
790d1437a8 fix(webconsole): validate ssh target ip against accessible resources (#25548)
The ssh console endpoint accepted a raw ip address in the URL path
and dialed it directly with user provided credentials, so any
authenticated user could use the webconsole service as an internal
SSH proxy, port scanner and credential brute-forcer.

Resolve the ip against hosts (access_ip) and servers (ip_addr)
visible to the user's session and require it to match one of them,
rejecting all other addresses.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 10:06:42 +08:00
Jian Qiu
5d5c052a9b fix(s3gateway): enforce signature freshness and single key decoding (#25536)
- Reject signed requests older or newer than 15 minutes (x-amz-date
  for v4, Date for v2), so intercepted valid requests can no longer
  be replayed indefinitely
- Decode the object key exactly once: r.URL.Path is already decoded
  by net/http and is the form the signature covers, decoding again
  let a signature for one key be replayed against another
  (e.g. %252e%252e%252f resolving to ../)
- Add unit tests for both behaviors

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 09:19:21 +08:00
Jian Qiu
3047bbdcda fix(webconsole): fetch kubeconfig with user session for k8s shell (#25539)
The k8s shell/log endpoints fetched the target cluster kubeconfig
with the admin session, bypassing RBAC and owner scope checks, so any
authenticated user could exec into pods and read logs of any
registered cluster.

Fetch the kubeconfig with the user's own session instead, so policy
checks and owner filtering apply, and command records are attributed
to the real user.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 09:18:17 +08:00
Jian Qiu
2321b65ee1 fix(webconsole): generate session key with crypto/rand (#25542)
The AES key encrypting all console session tokens was derived from
rand.Float32 with only about 24 bits of entropy, so it could be
recovered by brute force from a single known (session id, token)
pair and then be used to forge access tokens of arbitrary sessions.

Generate the key from 32 crypto/rand bytes instead (about 192 bits
of effective entropy). Sessions live in process memory only, so a
per-process random key is sufficient.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 09:17:22 +08:00
Jian Qiu
70391d0158 fix(webconsole): isolate RDP console sessions with unique ids (#25545)
RemoteRDPConsoleInfo.GetId() always returned an empty string, so all
RDP sessions were stored under the same key in the session manager:
any valid RDP access token resolved to whatever RDP session was stored
last, letting a user take over another user's console session
(host/port/username/password).

Give every RDP session a random UUID id and validate in
SSessionManager.Get that the presented access token is exactly the
one issued for the session, so sessions can not be reached by any
other token.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 09:16:43 +08:00
wanyaoqi
af794715fd fix(region): use host type hypervisor rather than kvm (#25533) 2026-09-03 23:54:21 +08:00
Jian Qiu
4f1706d285 fix(appsrv): do not honor CORS credentials for wildcard origins (#25517)
With cors_hosts unset (the default), the CORS middleware allowed all
origins and echoed the request origin together with
Access-Control-Allow-Credentials, so any website could make
authenticated cross-origin requests against the API with the user's
cookies.

Credentials are now only honored for explicitly listed origins:
with an empty or wildcard origin allowlist the middleware responds
with Access-Control-Allow-Origin: * and no credentials header.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 23:52:58 +08:00
Jian Qiu
d8facf9ffd fix: convert sql.ErrNoRows to 404 not found (#25524)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-03 23:50:55 +08:00
Jian Qiu
8cab0232c4 fix(notify): harden verification code generation and verification (#25528)
- Generate verification codes with crypto/rand instead of the
  predictable time-seeded math/rand
- Count failed verification attempts and invalidate the code after
  verify_max_attempts (default 5) failures, closing the unlimited
  online brute force of the 6-digit code at the login endpoint
- Delete the verification record after a successful check so codes
  are single use and can not be replayed within the validity window
- Add unit tests for code generation

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 23:48:40 +08:00
Jian Qiu
c053206af9 fix(hostimage): harden nbd export against command injection (#25531)
The nbd export endpoints interpolated the request disk_id into
shell commands (sh -c with the joined qemu-nbd command line, and
ps|grep for the process check), so a crafted disk_id could execute
arbitrary commands as root on the host image service.

- Require the disk_id to be a plain UUID in both export and close
  endpoints
- Run qemu-nbd with argv instead of sh -c
- Check the export process via its pid file and kill -0 instead of
  shell pipelines
- Add unit tests for the validation and the process check

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 23:47:31 +08:00
Zexi Li
a79c399c2f feat(llm): support cancel and resume for instant model import (#25520)
Allow deleting models to abort in-flight downloads, persist import input for resume-import after killed status, and clean up import cache on delete.
2026-09-03 20:14:35 +08:00
Jian Qiu
add8d8381f fix: mask tokens and passwords in logs (#25514)
Full session tokens were written to logs in policy.go, mcclient auth,
oidc handler and the webconsole session manager; guest root passwords
were logged by the linux fsdriver and the nbdkit mount command; MCP
tool arguments and full JSON-RPC bodies (which may contain
server-reset-password passwords) were logged by the llm agent.

Truncate tokens in log messages, drop or mask password values and
log only the tool/method name for MCP calls.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 19:27:34 +08:00
Jian Qiu
2b0d3d4362 fix(mcp-server): do not expose server-monitor as an MCP tool (#25512)
server-monitor sends arbitrary QMP/HMP commands (pmemsave, migrate,
etc.) to a running guest, which exceeds the semantics of monitoring.
As an MCP tool it could be triggered without human awareness through
LLM prompt injection or the tool-request endpoint.

Remove the mcp-desc registration tag from ServerMonitorOptions so
climcgen no longer registers the tool, drop its mention from the MCP
instructions, and add a regression test asserting it is not exposed.
The climc command and the permission-gated region API are unchanged.

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 19:21:31 +08:00
屈轩
413f014f9c fix(region): vmware vm nic sync (#25509) 2026-09-03 18:27:32 +08:00
Jian Qiu
32477e1620 fix(hostman): harden deployer command execution against shell injection (#25499)
- Quote the heredoc delimiter when writing deploy params to the
  deployer guest, so deploy content, passwords and other
  user-supplied values are written literally and shell expansions
  ($(...), backticks, $VAR) inside them are not evaluated by the
  remote shell
- Escape JSON passed via --deploy-params as a single POSIX shell
  word, preventing single quotes in user data from breaking out of
  the argument
- Add unit tests covering injection payloads

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 10:55:43 +08:00
Jian Qiu
4c3c641c97 fix(webconsole): harden climc shell command execution against injection (#25497)
- Run ssh via exec.Command argv instead of "bash -c" string
  interpolation, so user-supplied fields can no longer escape into
  local shell commands on the webconsole server
- Quote every interpolated value (env, command, args) as a POSIX
  shell word, so they stay literal data on the remote shell
- Validate username charset and limit target_ip to climc pod or container
- Add unit tests covering injection payloads

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 10:52:10 +08:00
Jian Qiu
919d2b9d56 fix: apigateway returns cors_hosts to browser (#25492)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-03 00:50:01 +08:00
Jian Qiu
4e798c8b95 fix: update rpm build scripts for openeuler (#25473)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-09-02 17:50:20 +08:00
Zexi Li
071e4fcb2d Automated cherry pick of #25479: Feat/aiproxy model context window (#25480)
* feat(aiproxy): expose catalog context window on AI models

Populate known 1M+ context windows in catalog seed data and return
context_window in AI model list/detail API responses.

* feat(aiproxy): add instance test-connectivity and set-models actions

Add provider instance test-connectivity using stored ai_key secrets,
and set-models to import selected catalog models while skipping
existing model_key rows. Register corresponding climc perform commands.
2026-09-02 17:43:42 +08:00
wanyaoqi
4126bd8494 fix(region): disable startrun snapshotsCleanup (#25448) 2026-09-02 15:59:16 +08:00
Zexi Li
90364a81df fix(cloudcommon): fix err shadowing in GetPropertyDistinctField (#25471) 2026-09-02 15:58:11 +08:00
屈轩
3bcd053ad5 fix(region): optimized cachedimage list (#25467) 2026-09-02 11:40:32 +08:00
屈轩
5ea244bc5c fix(region): support filter cloudprovider by regions (#25462) 2026-09-01 16:05:55 +08:00
屈轩
b3905f139e fix(region): vendor update (#25459) 2026-09-01 13:41:41 +08:00
屈轩
8b2994a380 fix(apigateway): update mcp api addr (#25455) 2026-09-01 13:41:14 +08:00
屈轩
637c5b15a2 fix(region): optimized cachedimage list (#25452) 2026-09-01 09:18:12 +08:00
Zexi Li
69f9ff2a61 feat(llm): add llm_sku clone API and climc support (#25444) 2026-08-27 19:01:15 +08:00
wanyaoqi
8abb4057f1 fix(region,host): optimize snapshot delete (#25441)
use rebase/commit merge backing chain instead of convert.
2026-08-27 16:22:54 +08:00
wanyaoqi
8f13c93e28 fix(region): batch detach isolated devices (#25434) 2026-08-27 13:48:53 +08:00
wanyaoqi
a61a824884 fix(region): set default stop time 300s (#25431) 2026-08-26 15:15:33 +08:00
Jian Qiu
cc2cd72a2f fix: allow empty host networks (#25427)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-26 14:39:06 +08:00
屈轩
fa594fe753 fix(region): not real delete glance cachedimage (#25425) 2026-08-25 14:59:03 +08:00
wanyaoqi
24a245fd4f fix(host-deployer): no noeed clean failed mountpoints (#25421) 2026-08-25 11:35:02 +08:00
Jian Qiu
dbb6c4a17b fix: allow create-disk without disk args (#25414)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-24 15:48:00 +08:00
屈轩
139df655b7 fix(region): v2v migrate uefi check (#25418) 2026-08-24 15:40:51 +08:00
屈轩
04c6ed26bf fix(glance): avoid receive notify when glance restart (#25411) 2026-08-24 15:05:14 +08:00
wanyaoqi
8bff91b52d fix(host-health,region): host health detect network available add check peers and default gw (#25384) 2026-08-24 14:34:20 +08:00
屈轩
2898cdb20a fix(region): skip hostlocal wire when detect vmware net (#25406) 2026-08-24 14:19:39 +08:00
屈轩
c8ddc8665f fix(region): show more error info for vmware net detect (#25402) 2026-08-24 10:21:33 +08:00
Zexi Li
9f318bbff6 feat(llm): merge SKU envs into container and validate prefer_hosts updates (#25388)
Allow local_path SKUs to update prefer_hosts on edit, and overlay SKU envs onto the primary container with same-key override.
2026-08-21 14:27:42 +08:00
Jian Qiu
8081f677c7 fix: unhandled errors (#25379)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-20 17:30:49 +08:00
Zexi Li
a2d0a0a799 fix(host): skip unmanage interface when bridge slave has no address (#25381)
Only call tryUnmanageInterface when the slave interface has IPv4 or IPv6
addresses configured, avoiding unnecessary NetworkManager unmanage on
address-less interfaces during bridge setup.
2026-08-20 17:27:10 +08:00
屈轩
d109f5bf35 fix(region): skip sync cloudpods kvm secgroup (#25378) 2026-08-20 10:45:13 +08:00
wanyaoqi
8aed63bd0d Automated cherry pick of #25365: fix(region,host): guest stop add timeout options,and check is_force (#25368)
* fix(host): check qga guest-get-osinfo error

* fix(region,host): guest stop add timeout options and is_force check
2026-08-20 10:15:27 +08:00
屈轩
c241fc1210 fix(region): support cloudpods ipset (#25374) 2026-08-19 18:56:38 +08:00
Zexi Li
388bba8e85 fix(scheduler): report HAMI hostFree before min-memory filter (#25370)
Shortage messages now show remaining GPU memory from matched devices
before min-memory filtering, so users see actual free capacity instead of 0.
2026-08-19 16:40:13 +08:00
Jian Qiu
945a042676 fix: storage set metadata in post_create (#25363)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-19 16:28:31 +08:00
Jian Qiu
0a06731b8d fix: deploy qga path not found error (#25350)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-19 13:24:02 +08:00
Zexi Li
425231aa64 feat(llm): check sku schedule info (#25361) 2026-08-19 12:18:07 +08:00
Zexi Li
89c3068a38 fix(aiproxy): remove 30-day limit on custom usage range (#25355)
Allow querying usage statistics over custom date ranges longer than 30 days.
2026-08-17 16:59:56 +08:00
wanyaoqi
f19c914dce Automated cherry pick of #25347: fix(host): add options enabel vendor/device pt usb dev (#25348)
* fix(host): add options enabel vendor/device pt usb dev

* fix(host): add audio device for spice driver
2026-08-16 11:24:10 +08:00
Jian Qiu
73265ea559 fix: disk backup filter by server_id (#25344)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-15 09:37:48 +08:00
Jian Qiu
636b81973c feature: support idp driver attribtue_names (#25333)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-15 00:15:56 +08:00
wanyaoqi
d7e4a43b9b Automated cherry pick of #25313: Fix/device add params2 (#25314)
* fix(host): device add params use interface

* fix(host): ide device addr
2026-08-15 00:15:40 +08:00
屈轩
ce2c4a588f fix(glance): detect windows iso (#25342) 2026-08-14 19:01:53 +08:00
Zexi Li
f6edc92c62 fix(monitor): nodata 告警补充 ResIdKey 与 AlertDetails (#25338)
为 CommonAlertMetricDetails 填充资源 ID tag,并在无数据评估匹配中序列化 AlertDetails,便于下游定位资源。
2026-08-14 16:08:57 +08:00
屈轩
4096e77d48 fix(glance): iso image detect (#25331) 2026-08-14 14:55:26 +08:00
屈轩
82b468de2e fix: update telegraf version (#25324) 2026-08-14 14:53:25 +08:00
Zexi Li
5b6170421e feat(aiproxy): encrypt ai_key and virtual_key secrets at rest (#25326)
Store provider secrets and virtual keys encrypted with AES, add hash-based VK lookup, and migrate existing plaintext rows on init.
2026-08-14 14:40:22 +08:00
Zexi Li
72eb994f68 feat(aiproxy): migrate catalog resources to virtual resource base (#25317)
Give ai providers/models/keys/routing-models project scope with tenant backfill, and pass project/domain on LLM sync upserts.
2026-08-14 10:43:15 +08:00
屈轩
b12fe78267 fix(cloudmon): cloudpods metric pull delay time (#25320) 2026-08-14 10:38:04 +08:00
屈轩
be6ae61ea4 fix: mcp streamable (#25312) 2026-08-13 16:37:34 +08:00
wanyaoqi
56a5dbcaaf fix(region): server-create add src ip/mac check (#25303) 2026-08-13 10:10:31 +08:00
Zexi Li
9db72a1b5c feat(llm): sync isolated devices by sharing_mode on restart (#25306)
Respect sharing_mode when attaching/restoring GPU devices in compute,
and sync LLM guest devices with SKU config instead of only detaching
when empty.
2026-08-12 20:17:07 +08:00
Zexi Li
1baffa21e6 feat(k8s): add cluster history-data-clean climc command (#25301) 2026-08-11 13:55:28 +08:00
Jian Qiu
dc12c191fd fix: dhcp distribute static routes on condition (#25294)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-10 10:31:15 +08:00
Zexi Li
b19a23c701 Automated cherry pick of #25222: fix(baremetal): add IPMI LAN channel discovery with fallback (#25293)
* fix(baremetal): add AllowFallback guard for OEM profile LAN channel authority

OEM/model profiles are authoritative allowlists; their LanChannels must not be overridden by the fallback 1..11 scan. Add AllowFallback bool to LanConfigSelectionOptions, wrapping the fallback scan in a conditional. When false (default), only preferred and persisted channels are probed.

* fix(baremetal): set AllowFallback on ipmiprobe and manager callers

manager.checkIpmiInfo: AllowFallback=false (profile always present). ipmiprobe.doRawIpmiProbe: AllowFallback=profile==nil (fallback only when no profile exists).

* fix(baremetal): set AllowFallback=false in configIPMISetting, cleanup bm_register

configIPMISetting: AllowFallback=false (profile always present). bm_register.updateIpmiInfo: replace profile-based channel scan with GetLanConfig on the registered channel directly.

---------

Co-authored-by: Bo Xu <i@186526.xyz>
2026-08-07 18:26:02 +08:00
Zexi Li
e8f046de4f fix(baremetal): tolerate ipmitool exit 1 when lan output is usable (#25290)
Some BMCs return exit status 1 from ipmitool lan print even with valid
LAN config, which caused IPMI probe to fail with no IPMI lan NotFoundError.
2026-08-07 17:22:30 +08:00
Zexi Li
9d0b4bb975 fix(lbagent): generate valid UDP health check config (#25288)
Signed-off-by: DadaVinqi <DadaVinqi@users.noreply.github.com>
Co-authored-by: DadaVinqi <DadaVinqi@users.noreply.github.com>
2026-08-07 14:31:20 +08:00
屈轩
50d78a7985 fix(region): change config stop options check (#25286) 2026-08-06 17:32:35 +08:00
wanyaoqi
3d173686fc fix(host): e1000 nic add hotplug support (#25282) 2026-08-06 10:48:36 +08:00
Zexi Li
5ea8def943 fix(llm): detach stale GPUs when effective devices are empty on restart (#25279)
Avoid keeping bound isolated devices after SKU/LLM devices are cleared;
desktop container setup now follows effective devices only.
2026-08-06 10:02:09 +08:00
Zexi Li
bd33225ed0 fix(aiproxy): keep weight=1 keys selectable after health penalty (#25275)
Integer truncation of base*mul/maxScore can yield 0 for weight=1 keys,
permanently excluding them; floor effective weight at 1 when mul > 0.
2026-08-04 20:24:58 +08:00
wanyaoqi
874f82dfac fix(host): slvm create from snapshot (#25266) 2026-08-04 10:23:14 +08:00
Zexi Li
9e89074f86 feat(host): support Hygon DCU HAMI vdev core allocation (#25273) 2026-08-04 10:20:41 +08:00
wanyaoqi
bd3a1d0de9 feat(glance): support nfs as backend storage (#25242) 2026-08-03 16:01:01 +08:00
Jian Qiu
e6621aacf0 fix: region backward compatible fields for isolated_devices (#25262)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-08-03 15:45:47 +08:00
Zexi Li
97fc254cf5 fix(llm): derive aiproxy upstream model key from local_path sku (#25271) 2026-08-03 15:44:45 +08:00
Zexi Li
3d3ae1ef0c feat(llm,host): support Hygon DCU runtime for vLLM containers (#25259)
Wire vendor on LLM SKU devices, resolve supplemental groups for DCU
access, source DTK env in vLLM entrypoint, and add hysmi telegraf metrics.
2026-08-02 13:51:58 +08:00
wanyaoqi
77aa8c9471 fix(region,host,monitor): hami misc fix (#25256)
- npusmi monitor support
2026-07-31 10:10:35 +08:00
Zexi Li
8d490d5a3a fix(seclib2): support Ed25519 encryption (#25253)
Handle Ed25519 public and private keys in the AES-based encryption path.

Add round-trip coverage for OpenSSH and PKCS#8 private keys.

Co-authored-by: hechang <117586514+Haibersut@users.noreply.github.com>
2026-07-30 10:35:36 +08:00
Jian Qiu
2911f92f9b fix: handle host listen interface and master nic (#25248)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-07-29 23:46:26 +08:00
Zexi Li
c1980ee7fe feat(host): support Hygon DCU isolated device and HAMI (#25246)
Add Hygon DCU container device passthrough, HAMI vGPU sharing, vendor
field/filter on isolated devices, remote path helper, and LLM SKU mapping.
2026-07-29 19:01:41 +08:00
Zexi Li
203f5bb396 fix(llm): require memory_mb for local_path HAMi devices (#25244)
local_path SKUs cannot estimate VRAM from InstantModel; validate HAMi devices set devices[].memory_mb on create/update and deployment.
2026-07-29 10:22:39 +08:00
wanyaoqi
4a729a7639 Automated cherry pick of #25206: fix(region,host): host register with correct hostname (#25232)
* fix(region,host): host register with correct hostname

* fix(region): guest migrate reuse create params schedtag
2026-07-28 10:59:34 +08:00
wanyaoqi
712ce702ce feat(host): support ascend npu hami (#25236) 2026-07-28 10:55:50 +08:00
Zexi Li
80ea7e60e1 feat(baremetal): support IPMI RMCP+ cipher suite probe and persist (#25238)
Auto-detect working ipmitool -C suites (0/3/17) and store cipher_suite on host IPMI config for subsequent connections.
2026-07-28 10:27:06 +08:00
屈轩
daf21aa6a3 fix(climc): climc options update (#25228) 2026-07-27 19:41:40 +08:00
wanyaoqi
6bc05ca5c1 fix(host): qmp device add convert string to boolean (#25231) 2026-07-27 19:08:27 +08:00
Zexi Li
ea0e1ca42f fix(aiproxy): avoid permanent ai_key blacklist and clarify skip reasons (#25224)
Enter cooldown when health score hits zero so keys can recover, and include concrete skip reasons in resolve errors.
2026-07-27 12:15:55 +08:00
Jian Qiu
94862f5232 feature: ip set support (#25219)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-07-24 16:34:50 +08:00
Jian Qiu
887bb943b8 fix: vpc distributes static routes (#25212)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-07-23 19:12:22 +08:00
Zexi Li
205dbe2f64 feat(llm): support hami (#25214) 2026-07-23 17:08:26 +08:00
Jian Qiu
554cdd01be fix: baremetal use legacy http client (#25209)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-07-23 16:33:36 +08:00
屈轩
da03a421a5 fix(region): vendor update (#25205) 2026-07-22 11:19:14 +08:00
Jian Qiu
06517e5aa6 fix: allow empty ip on link-local nic (#25201)
Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
2026-07-22 11:06:30 +08:00
屈轩
6d49bee5e6 fix(region): vendor update (#25198) 2026-07-21 23:38:10 +08:00
屈轩
033408e7d0 fix(mcp-server): optimized mcp desc (#25195) 2026-07-21 16:19:30 +08:00
屈轩
fe1846f690 fix(mcp-server): support forwarding mcp-server request (#25192) 2026-07-21 13:27:46 +08:00
屈轩
7aa20811f5 fix(mcp-server): optimized mcp server options (#25189) 2026-07-20 20:58:37 +08:00
屈轩
93971e83fb fix(mcp-server): mcp use climc struct (#25185) 2026-07-20 17:17:27 +08:00
wanyaoqi
1950824dfa feat(region,host,scheduler): isolated device refactor and hami support (#25181) 2026-07-20 13:20:49 +08:00
wanyaoqi
7fec30216b fix(host-deployer): bond netplan add vlan subinterface config (#25179) 2026-07-20 12:19:46 +08:00
wanyaoqi
23e1910817 fix(region): baremetal boot mode decided by selected host on image support both uefi&bios (#25167) 2026-07-20 12:07:39 +08:00
wanyaoqi
a8f3474fb2 fix(host-deployer): windows disk extend separate partition and filesystem (#25168) 2026-07-20 12:06:33 +08:00
wanyaoqi
bee68803b2 fix(host): add nest virtualization options (#25169) 2026-07-20 12:03:50 +08:00
wanyaoqi
f58fcc2999 fix(region): disk change driver support change aio_mode and cache_mode (#25170) 2026-07-20 11:57:55 +08:00
屈轩
fcbf833fde fix(mcp-server): query scope params (#25173) 2026-07-17 00:26:57 +08:00
屈轩
af2e410c24 fix(cloudid): batch detach policy (#25165) 2026-07-16 19:27:36 +08:00
屈轩
f6e32e7707 fix(region): support cas hypervisor (#25162) 2026-07-16 19:08:01 +08:00
Zexi Li
277546c95c fix(aiproxy): emit done events before response.completed in stream converter (#25159)
Emit output_item.done, reasoning_summary_part.done, function_call_arguments.done,
and related finalize events for text, reasoning, and tool calls before the
response.completed event to match the Responses API stream contract.
2026-07-16 17:04:02 +08:00
1060 changed files with 43326 additions and 186388 deletions

View File

@@ -261,7 +261,7 @@ dep:
@echo "$$depDeprecated"
@$(MAKE) mod
RELEASE_BRANCH:=release/4.0
RELEASE_BRANCH:=release/4.0.4
GOPROXY ?= direct
mod:

View File

@@ -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

View File

@@ -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

View File

@@ -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>"

View File

@@ -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-*

View File

@@ -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

View File

@@ -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)
}

View 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
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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{})

View File

@@ -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"`
}

View File

@@ -34,16 +34,18 @@ func init() {
cmd.Perform("purge", &compute.CloudregionPurgeOptions{})
type CloudregionListOptions struct {
_ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。指定云厂商时必须传 provider例如创建阿里云provider=[\"Aliyun\"]。创建虚拟机时必须 usable=trueMCP 默认注入)。查完后继续 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:"只列网络可用的区域;创建虚拟机时必须为 trueMCP 默认注入 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

View File

@@ -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

View File

@@ -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{})

View File

@@ -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

View File

@@ -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{})

View File

@@ -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{})

View File

@@ -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{})

View 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{})
}

View File

@@ -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{})
}

View File

@@ -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{})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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{})

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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{})
}

View File

@@ -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{})

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -24,16 +24,18 @@ import (
func init() {
type SnapshotsListOptions struct {
_ struct{} `mcp-desc:"列出磁盘快照。可用 disk/server/disk-type 过滤"`
options.BaseListOptions
Disk string `help:"Disk snapshots" json:"disk_id"`
FakeDeleted bool `help:"Show fake deleted snapshot or not"`
Disk string `help:"Disk snapshots" json:"disk_id" mcp:"true"`
FakeDeleted bool `help:"Show fake deleted snapshot or not" mcp:"true"`
Local *bool `help:"Show local snapshots"`
Share *bool `help:"Show shared snapshots"`
DiskType string `help:"Filter by disk type" choices:"sys|data"`
Server string `help:"Filter by server" json:"server_id"`
Unused bool
StorageId string `help:"Filter by storage id"`
DiskType string `help:"Filter by disk type" choices:"sys|data" mcp:"true"`
Server string `help:"Filter by server" json:"server_id" mcp:"true"`
Unused bool `mcp:"true"`
StorageId string `help:"Filter by storage id" mcp:"true"`
OrderByGuest string
OrderByDiskName string

View File

@@ -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"`
}

View File

@@ -31,17 +31,19 @@ import (
)
type GeneralUsageOptions struct {
HostType []string `help:"Host types" choices:"hypervisor|baremetal|esxi|xen|kubelet|hyperv|aliyun|azure|aws|huawei|qcloud|openstack|ucloud|zstack|google|ctyun|cnware"`
Provider []string `help:"Provider" choices:"OneCloud|VMware|Aliyun|Azure|Aws|Qcloud|Huawei|OpenStack|Ucloud|VolcEngine|ZStack|Google|Ctyun|CNWare"`
Brand []string `help:"Brands" choices:"OneCloud|VMware|Aliyun|Azure|Aws|Qcloud|Huawei|OpenStack|Ucloud|VolcEngine|ZStack|Google|Ctyun|CNWare"`
Project string `help:"show usage of specified project"`
_ struct{} `mcp-desc:"资源用量汇总usages/general-usage。报表「资源概览」用本工具不要用各资源 list 凑。项目级传 project=项目名/id域级传 project-domain平台级 scope=system。返回 servers/rds/cache/loadbalancer/buckets/eip/snapshot/vpcs/networks/ports 及 running_servers、disks.attached 等"`
ProjectDomain string `help:"show usage of specified domain"`
HostType []string `help:"Host types" choices:"hypervisor|baremetal|esxi|xen|kubelet|hyperv|aliyun|azure|aws|huawei|qcloud|openstack|ucloud|zstack|google|ctyun|cnware" mcp:"true"`
Provider []string `help:"Provider" choices:"OneCloud|VMware|Aliyun|Azure|Aws|Qcloud|Huawei|OpenStack|Ucloud|VolcEngine|ZStack|Google|Ctyun|CNWare" mcp:"true"`
Brand []string `help:"Brands" choices:"OneCloud|VMware|Aliyun|Azure|Aws|Qcloud|Huawei|OpenStack|Ucloud|VolcEngine|ZStack|Google|Ctyun|CNWare" mcp:"true"`
Project string `help:"show usage of specified project" mcp:"true"`
CloudEnv string `help:"show usage of specified cloudenv" choices:"public|private|onpremise"`
Scope string `help:"show usage of specified privilege scope" choices:"system|domain|project"`
ProjectDomain string `help:"show usage of specified domain" mcp:"true"`
Refresh bool `help:"force refresh usage statistics"`
CloudEnv string `help:"show usage of specified cloudenv" choices:"public|private|onpremise" mcp:"true"`
Scope string `help:"show usage of specified privilege scope" choices:"system|domain|project" mcp:"true"`
Refresh bool `help:"force refresh usage statistics" mcp:"true"`
}
func fetchHostTypeOptions(args *GeneralUsageOptions) *jsonutils.JSONDict {

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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"`

View File

@@ -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

View File

@@ -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"`

View File

@@ -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()

View File

@@ -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
})
}

View File

@@ -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()

View File

@@ -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"`

View File

@@ -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()

View File

@@ -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

View File

@@ -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"`

View File

@@ -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")

View File

@@ -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 != "" {

View File

@@ -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"

View File

@@ -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{})
}

View File

@@ -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 {

View File

@@ -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
)

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 callbackJSON 输出返回给客户端
## 主要功能
## 扩展工具
- 统一管理多云资源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 即可注册。

View File

@@ -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:

27
go.mod
View File

@@ -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
@@ -48,7 +51,7 @@ require (
github.com/lestrrat-go/jwx v1.2.25
github.com/lestrrat/go-jwx v0.0.0-20180221005942-b7d4802280ae
github.com/libvirt/libvirt-go-xml v5.2.0+incompatible
github.com/mark3labs/mcp-go v0.42.0
github.com/mark3labs/mcp-go v0.48.0
github.com/mattn/go-sqlite3 v1.14.19
github.com/mdlayher/arp v0.0.0-20190313224443-98a83c8a2717
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7
@@ -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.20260904120449-8aa2a5d6ba7a
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
@@ -197,7 +197,6 @@ require (
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/checkpoint-restore/go-criu/v5 v5.3.0 // indirect
@@ -251,9 +250,10 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
@@ -267,7 +267,6 @@ require (
github.com/icholy/digest v1.1.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jdcloud-api/jdcloud-sdk-go v1.55.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -382,7 +381,6 @@ require (
github.com/vmware/govmomi v0.37.1 // indirect
github.com/volcengine/ve-tos-golang-sdk/v2 v2.6.2 // indirect
github.com/volcengine/volc-sdk-golang v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 // indirect
github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
@@ -403,7 +401,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

32
go.sum
View File

@@ -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=
@@ -276,8 +274,6 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/c-bata/go-prompt v0.2.4 h1:7pKUJ3CUgzdu1HJeWhNRkpVyY/NnlJhM/7d6YgHNOao=
github.com/c-bata/go-prompt v0.2.4/go.mod h1:PqlttLXp0E7bZcoDW+dmzyKqFbmQTFoNzGSuW/AQRmo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -579,8 +575,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -589,6 +585,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.17 h1:rMrlX2ZY2UbvT+sdz3+6J+pp2z+msCq9MxTU6ymxbBY=
github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -669,8 +667,6 @@ github.com/influxdata/influxql v1.1.0 h1:sPsaumLFRPMwR5QtD3Up54HXpNND8Eu7G1vQFmi
github.com/influxdata/influxql v1.1.0/go.mod h1:KpVI7okXjK6PRi3Z5B+mtKZli+R1DnZgb3N+tzevNgo=
github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 h1:w66aaP3c6SIQ0pi3QH1Tb4AMO3aWoEPxd1CNvLphbkA=
github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9/go.mod h1:BaIJzjD2ZnHmx2acPF6XfGLPzNCMiBbMRqJr+8/8uRI=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jaypipes/ghw v0.11.0 h1:i0pKvAM7eZk0KvLm9vzpcpDKTRnfR6AQ5pFkPVnYJXU=
github.com/jaypipes/ghw v0.11.0/go.mod h1:jeJGbkRB2lL3/gxYzNYzEDETV1ZJ56OKr+CSeSEym+g=
github.com/jdcloud-api/jdcloud-sdk-go v1.55.0 h1:mzVj8r6fluEwjn8ogqtGfYW2qSIVUaEq0JAsvjCav3A=
@@ -766,8 +762,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.42.0 h1:gk/8nYJh8t3yroCAOBhNbYsM9TCKvkM13I5t5Hfu6Ls=
github.com/mark3labs/mcp-go v0.42.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -1178,8 +1174,6 @@ github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9
github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 h1:6932x8ltq1w4utjmfMPVj09jdMlkY0aiA6+Skbtl3/c=
github.com/xuri/efp v0.0.0-20220603152613-6918739fd470/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.7.1 h1:gm8q0UCAyaTt3MEF5wWMjVdmthm2EHAWesGSKS9tdVI=
@@ -1783,13 +1777,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.20260904120449-8aa2a5d6ba7a h1:XlmHEv2UN01+eVQqv+yO3y2yEq7QpkcQbMFu4p7h7FU=
yunion.io/x/cloudmux v0.3.10-0-alpha.1.0.20260904120449-8aa2a5d6ba7a/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 +1792,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=

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {

View 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)
}
}

View File

@@ -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")

View File

@@ -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.

View File

@@ -22,6 +22,7 @@ import (
"strings"
"golang.org/x/term"
"yunion.io/x/pkg/errors"
)

View File

@@ -36,6 +36,7 @@ import (
)
const headerAiVirtualKey = "X-Ai-Virtual-Key"
const headerAiRoutingId = "X-Ai-Routing-Id"
func extractVirtualKey(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get(headerAiVirtualKey)); v != "" {
@@ -49,6 +50,10 @@ func extractVirtualKey(r *http.Request) string {
return ""
}
func extractRoutingId(r *http.Request) string {
return strings.TrimSpace(r.Header.Get(headerAiRoutingId))
}
func upstreamErrorStatusCode(uerr *upstream.Error) int {
if uerr == nil || uerr.StatusCode <= 0 {
return 0
@@ -141,7 +146,7 @@ func chatCompletionsHandler(ctx context.Context, w http.ResponseWriter, r *http.
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -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

View File

@@ -71,7 +71,7 @@ func completionsHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -69,7 +69,7 @@ func embeddingsHandler(ctx context.Context, w http.ResponseWriter, r *http.Reque
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -69,7 +69,7 @@ func imagesGenerationsHandler(ctx context.Context, w http.ResponseWriter, r *htt
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -77,7 +77,7 @@ func messagesHandler(ctx context.Context, w http.ResponseWriter, r *http.Request
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -97,7 +97,7 @@ func handleResponsesCreate(ctx context.Context, w http.ResponseWriter, r *http.R
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
@@ -293,7 +293,7 @@ func handleResponsesSubResource(ctx context.Context, w http.ResponseWriter, r *h
writeResponsesError(ctx, w, http.StatusBadRequest, "invalid_request_error", "model query parameter is required")
return
}
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
httperrors.GeneralServerError(ctx, w, err)

View File

@@ -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 {

View 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"])
}
}

View 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)
}

View 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)
}
}

View File

@@ -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
}

View 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)
}
}

View File

@@ -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")
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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(

View File

@@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show More