From 1da2197a6cb51365a07ab274ba17b2f3f6dea7d2 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Tue, 14 Jul 2026 19:33:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=A0=E9=81=93=E6=A8=A1=E5=9E=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E6=B5=8B=E8=AF=95=E7=AB=AF=E7=82=B9,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A2=E6=B5=8B=E6=97=A0=E9=85=8D=E9=A2=9D?= =?UTF-8?q?=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++ docs/docs.go | 94 +++++++++++++++++ docs/swagger.json | 94 +++++++++++++++++ docs/swagger.yaml | 59 +++++++++++ go.sum | 100 +++++++++++++++++++ internal/api/aiadmin.go | 49 +++++++++ internal/api/routes_ai.go | 2 + internal/model/models.go | 8 +- internal/oci/genai.go | 7 +- internal/service/aigateway.go | 129 +++++++++++++++++++++--- internal/service/aigateway_test.go | 155 ++++++++++++++++++++++++++++- 11 files changed, 688 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c76d88b..a6cf11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 +## [0.6.0] + +### Added + +- 渠道模型缓存列表端点 `GET /api/v1/ai-channels/{id}/models`:黑名单模型查询层兜底排除,「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤) +- 单模型测试端点 `POST /api/v1/ai-channels/{id}/test-model`:对指定模型发 max_tokens=16 试调,通过即写入渠道「探测验证模型」(此后手动探测与每日后台任务将其置于试调候选首位),渠道探测状态不为可用时顺带置可用并复位熔断;未通过如实返回上游错误且不改动渠道状态 +- 渠道列表响应回填 `modelCount`(模型缓存计数,与模型列表同口径:排除黑名单、随过滤弃用开关) + +### Fixed + +- 修复渠道探测「无配额」误判:配额试调遇 401/403/鉴权 404 不再立即定论租户无配额(可能仅个别模型无权限),改为继续尝试其余候选,任一成功即判可用,全部失败且出现过鉴权拒绝才判无配额 +- 探测试调 `max_output_tokens` 由 1 提升到 16:openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判 + ## [0.5.1] ### Fixed diff --git a/docs/docs.go b/docs/docs.go index 9d8de37..aa053cb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -458,6 +458,36 @@ const docTemplate = `{ } } }, + "/api/v1/ai-channels/{id}/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "渠道模型缓存列表", + "parameters": [ + { + "type": "integer", + "description": "渠道 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache" + } + } + } + } + }, "/api/v1/ai-channels/{id}/probe": { "post": { "security": [ @@ -518,6 +548,51 @@ const docTemplate = `{ } } }, + "/api/v1/ai-channels/{id}/test-model": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)", + "parameters": [ + { + "type": "integer", + "description": "渠道 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "模型名", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.testChannelModelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_model.AiChannel" + } + }, + "502": { + "description": "试调未通过", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } + } + } + } + }, "/api/v1/ai-content-logs": { "get": { "security": [ @@ -6732,6 +6807,17 @@ const docTemplate = `{ } } }, + "internal_api.testChannelModelRequest": { + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "string" + } + } + }, "internal_api.tokenResponse": { "type": "object", "properties": { @@ -7921,6 +8007,10 @@ const docTemplate = `{ "lastProbeAt": { "type": "string" }, + "modelCount": { + "description": "ModelCount 是渠道模型缓存计数,列表查询回填,不落库", + "type": "integer" + }, "name": { "type": "string" }, @@ -7933,6 +8023,10 @@ const docTemplate = `{ "probeError": { "type": "string" }, + "probeModel": { + "description": "ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位", + "type": "string" + }, "probeStatus": { "description": "ok / no_service / no_quota / error", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 2ae5293..4d731a9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -451,6 +451,36 @@ } } }, + "/api/v1/ai-channels/{id}/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "渠道模型缓存列表", + "parameters": [ + { + "type": "integer", + "description": "渠道 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache" + } + } + } + } + }, "/api/v1/ai-channels/{id}/probe": { "post": { "security": [ @@ -511,6 +541,51 @@ } } }, + "/api/v1/ai-channels/{id}/test-model": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "AI 管理" + ], + "summary": "测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)", + "parameters": [ + { + "type": "integer", + "description": "渠道 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "模型名", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_api.testChannelModelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oci-portal_internal_model.AiChannel" + } + }, + "502": { + "description": "试调未通过", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } + } + } + } + }, "/api/v1/ai-content-logs": { "get": { "security": [ @@ -6725,6 +6800,17 @@ } } }, + "internal_api.testChannelModelRequest": { + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "string" + } + } + }, "internal_api.tokenResponse": { "type": "object", "properties": { @@ -7914,6 +8000,10 @@ "lastProbeAt": { "type": "string" }, + "modelCount": { + "description": "ModelCount 是渠道模型缓存计数,列表查询回填,不落库", + "type": "integer" + }, "name": { "type": "string" }, @@ -7926,6 +8016,10 @@ "probeError": { "type": "string" }, + "probeModel": { + "description": "ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位", + "type": "string" + }, "probeStatus": { "description": "ok / no_service / no_quota / error", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d3ace40..70670eb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -548,6 +548,13 @@ definitions: required: - regionKey type: object + internal_api.testChannelModelRequest: + properties: + model: + type: string + required: + - model + type: object internal_api.tokenResponse: properties: expiresAt: @@ -1320,6 +1327,9 @@ definitions: type: integer lastProbeAt: type: string + modelCount: + description: ModelCount 是渠道模型缓存计数,列表查询回填,不落库 + type: integer name: type: string ociConfigId: @@ -1328,6 +1338,9 @@ definitions: type: integer probeError: type: string + probeModel: + description: ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位 + type: string probeStatus: description: ok / no_service / no_quota / error type: string @@ -2923,6 +2936,24 @@ paths: summary: 更新 AI 渠道 tags: - AI 管理 + /api/v1/ai-channels/{id}/models: + get: + parameters: + - description: 渠道 ID + in: path + name: id + required: true + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_model_AiModelCache' + security: + - BearerAuth: [] + summary: 渠道模型缓存列表 + tags: + - AI 管理 /api/v1/ai-channels/{id}/probe: post: parameters: @@ -2959,6 +2990,34 @@ paths: summary: 同步渠道模型缓存 tags: - AI 管理 + /api/v1/ai-channels/{id}/test-model: + post: + parameters: + - description: 渠道 ID + in: path + name: id + required: true + type: integer + - description: 模型名 + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_api.testChannelModelRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/oci-portal_internal_model.AiChannel' + "502": + description: 试调未通过 + schema: + $ref: '#/definitions/internal_api.errorResponse' + security: + - BearerAuth: [] + summary: 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用) + tags: + - AI 管理 /api/v1/ai-content-logs: get: responses: diff --git a/go.sum b/go.sum index 9f97458..57c52f2 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,40 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.2.2 h1:irlAloIzZaJ5RP/+UcaT1Nw0H9on2HKWdRehCsbJWJw= +github.com/PuerkitoBio/purell v1.2.2/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -29,14 +45,20 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -44,13 +66,35 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE= +github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= +github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= +github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= +github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= +github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= +github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= +github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= +github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= +github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -59,14 +103,22 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -74,8 +126,11 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -84,6 +139,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -96,6 +153,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -109,8 +168,12 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -118,11 +181,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oracle/oci-go-sdk/v65 v65.120.0 h1:qpGdts2Yleg6TdmtxXkL8MAnsASO+SOG+iG/Nd8wUGk= github.com/oracle/oci-go-sdk/v65 v65.120.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -131,6 +198,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -175,17 +244,27 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= +golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= @@ -193,12 +272,16 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -209,11 +292,14 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -221,6 +307,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -228,9 +316,13 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -254,11 +346,19 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/api/aiadmin.go b/internal/api/aiadmin.go index ae026e3..098798a 100644 --- a/internal/api/aiadmin.go +++ b/internal/api/aiadmin.go @@ -274,6 +274,55 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": models}) } +// @Summary 渠道模型缓存列表 +// @Tags AI 管理 +// @Param id path int true "渠道 ID" +// @Success 200 {object} itemsResponse[model.AiModelCache] +// @Security BearerAuth +// @Router /api/v1/ai-channels/{id}/models [get] +func (h *aiAdminHandler) listChannelModels(c *gin.Context) { + id, ok := aiPathID(c) + if !ok { + return + } + models, err := h.gw.ChannelModels(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": models}) +} + +type testChannelModelRequest struct { + Model string `json:"model" binding:"required"` +} + +// @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用) +// @Tags AI 管理 +// @Param id path int true "渠道 ID" +// @Param body body testChannelModelRequest true "模型名" +// @Success 200 {object} model.AiChannel +// @Failure 502 {object} errorResponse "试调未通过" +// @Security BearerAuth +// @Router /api/v1/ai-channels/{id}/test-model [post] +func (h *aiAdminHandler) testChannelModel(c *gin.Context) { + id, ok := aiPathID(c) + if !ok { + return + } + var req testChannelModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, ch) +} + // ---- 聚合模型与调用日志 ---- // @Summary ---- 聚合模型与调用日志 ---- diff --git a/internal/api/routes_ai.go b/internal/api/routes_ai.go index fd8ad13..614c43e 100644 --- a/internal/api/routes_ai.go +++ b/internal/api/routes_ai.go @@ -36,6 +36,8 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel) secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel) secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels) + secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels) + secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel) secured.GET("/ai-models", aiadmin.gatewayModels) secured.GET("/ai-settings", aiadmin.aiSettings) secured.PUT("/ai-settings", aiadmin.updateAiSettings) diff --git a/internal/model/models.go b/internal/model/models.go index ad3c6f5..1b9cfee 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -268,8 +268,12 @@ type AiChannel struct { LastProbeAt *time.Time `json:"lastProbeAt"` ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error ProbeError string `gorm:"size:512" json:"probeError"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + // ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位 + ProbeModel string `gorm:"size:96" json:"probeModel"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // ModelCount 是渠道模型缓存计数,列表查询回填,不落库 + ModelCount int64 `gorm:"-" json:"modelCount"` } // AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。 diff --git a/internal/oci/genai.go b/internal/oci/genai.go index d79bf60..6c3a34a 100644 --- a/internal/oci/genai.go +++ b/internal/oci/genai.go @@ -149,11 +149,12 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string { return out } -// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码; -// modelName 决定请求格式(cohere.* 走 COHERE)。 +// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道; +// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16: +// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。 func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) { body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi", - "max_output_tokens": 1, "store": false}) + "max_output_tokens": 16, "store": false}) if err != nil { return 0, err } diff --git a/internal/service/aigateway.go b/internal/service/aigateway.go index 4767a33..8303507 100644 --- a/internal/service/aigateway.go +++ b/internal/service/aigateway.go @@ -268,11 +268,34 @@ func valueOr(p *int, def int) int { return def } -// Channels 列出全部渠道。 +// Channels 列出全部渠道并回填各自的模型缓存计数。 func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) { var chs []model.AiChannel - err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error - return chs, err + if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil { + return nil, err + } + var rows []struct { + ChannelID uint + N int64 + } + q := s.db.WithContext(ctx).Model(&model.AiModelCache{}). + Select("channel_id, COUNT(*) AS n"). + Where("name NOT IN (SELECT name FROM ai_model_blacklists)") + if s.FilterDeprecated() { + q = q.Where("deprecated_at IS NULL") + } + err := q.Group("channel_id").Scan(&rows).Error + if err != nil { + return nil, err + } + counts := make(map[uint]int64, len(rows)) + for _, r := range rows { + counts[r.ChannelID] = r.N + } + for i := range chs { + chs[i].ModelCount = counts[chs[i].ID] + } + return chs, nil } // UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。 @@ -315,7 +338,7 @@ func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error { // ---- 探测与模型同步 ---- -// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。 +// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → 极小 max_tokens 配额试调。 func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) { var ch model.AiChannel if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { @@ -364,13 +387,15 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch * return s.probeChat(ctx, cred, ch, models) } -// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体 -// 不存在 404)换下一个候选,错误信息带模型名供用户加入黑名单;401/403 与鉴权类 404 -// 属租户级直接定论 no_quota;其余错误(元数据标 CHAT 但实际不可对话等)累计 3 次止损。 +// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需 +// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入 +// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选 +// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。 func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) { status, detail := "error", "无可试调对话模型" + quotaDetail := "" errBudget := 3 - for _, m := range probeCandidates(models) { + for _, m := range probeCandidates(ch.ProbeModel, models) { code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name) switch { case code == 200 || code == 429: @@ -378,7 +403,7 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, case oci.IsModelUnavailable(err): status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name)) case code == 401 || code == 403 || code == 404: - return "no_quota", truncateErr(oci.CompactError(err)) + quotaDetail = truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err))) default: status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err))) if errBudget--; errBudget == 0 { @@ -386,6 +411,9 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, } } } + if quotaDetail != "" { + return "no_quota", quotaDetail + } return status, detail } @@ -393,13 +421,18 @@ func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, // 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。 const probeCandidateCap = 8 -// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先; +// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的 +// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先; // voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算; // 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败), // 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。 -func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel { - var sorted []oci.GenAiModel +func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel { + var pinned, sorted []oci.GenAiModel for _, m := range models { + if probeModel != "" && m.Name == probeModel { + pinned = append(pinned, m) + continue + } if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 { sorted = append(sorted, m) } @@ -407,7 +440,7 @@ func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel { sort.SliceStable(sorted, func(i, j int) bool { return probeScore(sorted[i].Name) > probeScore(sorted[j].Name) }) - return diversifyByVendor(sorted, probeCandidateCap) + return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...) } // diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。 @@ -526,12 +559,80 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo }) } +// channelModels 列出渠道模型缓存;黑名单模型查询层兜底过滤 +// (拉黑即删缓存,正常不会残留,防御旧数据 / 并发窗口); +// 「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)。 func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) { + q := s.db.WithContext(ctx).Where("channel_id = ?", channelID). + Where("name NOT IN (SELECT name FROM ai_model_blacklists)") + if s.FilterDeprecated() { + q = q.Where("deprecated_at IS NULL") + } var rows []model.AiModelCache - err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error + err := q.Order("name ASC").Find(&rows).Error return rows, err } +// ChannelModels 列出渠道的模型缓存(名称排序),渠道不存在时报错。 +func (s *AiGatewayService) ChannelModels(ctx context.Context, id uint) ([]model.AiModelCache, error) { + var n int64 + if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Count(&n).Error; err != nil { + return nil, err + } + if n == 0 { + return nil, fmt.Errorf("渠道不存在") + } + return s.channelModels(ctx, id) +} + +// TestChannelModel 对渠道缓存中的指定模型发极小试调;通过时把该模型设 +// 为渠道探测验证模型(此后探测置于候选首位),渠道探测状态不为 ok 时顺带置 ok 并 +// 复位熔断;未通过仅返回错误,不改动渠道状态。 +func (s *AiGatewayService) TestChannelModel(ctx context.Context, id uint, name string) (*model.AiChannel, error) { + var ch model.AiChannel + if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { + return nil, fmt.Errorf("渠道不存在") + } + var mc model.AiModelCache + err := s.db.WithContext(ctx).Where("channel_id = ? AND name = ?", id, name).First(&mc).Error + if err != nil { + return nil, fmt.Errorf("模型不在该渠道缓存中,请先同步模型") + } + cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID) + if err != nil { + return nil, err + } + code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, mc.ModelOcid, mc.Name) + if code != 200 && code != 429 { + msg := fmt.Sprintf("HTTP %d", code) + if err != nil { + msg = oci.CompactError(err) + } + return nil, fmt.Errorf("测试未通过:%s", truncateErr(msg)) + } + return s.adoptProbeModel(ctx, &ch, name) +} + +// adoptProbeModel 记录探测验证模型并返回更新后的渠道; +// 状态不为 ok 时一并置 ok 并复位熔断。 +func (s *AiGatewayService) adoptProbeModel(ctx context.Context, ch *model.AiChannel, name string) (*model.AiChannel, error) { + updates := map[string]any{"probe_model": name} + if ch.ProbeStatus != "ok" { + updates["probe_status"] = "ok" + updates["probe_error"] = "" + updates["last_probe_at"] = time.Now() + updates["fail_count"] = 0 + updates["disabled_until"] = gorm.Expr("NULL") + } + if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil { + return nil, err + } + // 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值 + var fresh model.AiChannel + err := s.db.WithContext(ctx).First(&fresh, ch.ID).Error + return &fresh, err +} + // GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models; // group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。 func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) { diff --git a/internal/service/aigateway_test.go b/internal/service/aigateway_test.go index 4b426e9..48dc12e 100644 --- a/internal/service/aigateway_test.go +++ b/internal/service/aigateway_test.go @@ -453,7 +453,7 @@ func TestProbeCandidates(t *testing.T) { {Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"}, {Ocid: "o4", Name: "google.gemini-2.5-flash"}, } - got := probeCandidates(models) + got := probeCandidates("", models) if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" { t.Errorf("候选排序 = %+v", got) } @@ -473,7 +473,7 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) { {Ocid: "c1", Name: "cohere.command-a-03-2025"}, {Ocid: "g1", Name: "xai.grok-4"}, } - got := probeCandidates(models) + got := probeCandidates("", models) if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" { t.Fatalf("上限内全量返回且最高分居首: %+v", got) } @@ -489,11 +489,38 @@ func TestProbeCandidatesVendorDiversity(t *testing.T) { for i := 0; i < 12; i++ { many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)}) } - if capped := probeCandidates(many); len(capped) != probeCandidateCap { + if capped := probeCandidates("", many); len(capped) != probeCandidateCap { t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped)) } } +// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。 +func TestProbeCandidatesPinsProbeModel(t *testing.T) { + models := []oci.GenAiModel{ + {Ocid: "o2", Name: "cohere.command-r-plus"}, + {Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"}, + {Ocid: "o4", Name: "xai.grok-4"}, + } + tests := []struct { + name string + probeModel string + wantFirst string + wantLen int + }{ + {"验证模型置首位", "xai.grok-4", "xai.grok-4", 3}, + {"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3}, + {"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := probeCandidates(tt.probeModel, models) + if len(got) != tt.wantLen || got[0].Name != tt.wantFirst { + t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst) + } + }) + } +} + // entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。 func entityNotFoundErr() stubServiceError { return stubServiceError{status: 404, @@ -560,6 +587,128 @@ func TestProbeAuth404StillNoQuota(t *testing.T) { } } +// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。 +func TestChannelModelsExcludeBlacklist(t *testing.T) { + gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + cfg := importAliveConfig(t, svc) + ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + ctx := context.Background() + + // 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口 + gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"}) + rows, err := gw.ChannelModels(ctx, ch.ID) + if err != nil || len(rows) != 0 { + t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err) + } + chs, err := gw.Channels(ctx) + if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 { + t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err) + } +} + +// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于 +// 渠道模型列表与数量统计(数据保留,展示口径过滤)。 +func TestChannelModelsFilterDeprecated(t *testing.T) { + gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + cfg := importAliveConfig(t, svc) + ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + dep := time.Now().Add(-24 * time.Hour) + gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3", + Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep}) + ctx := context.Background() + + rows, _ := gw.ChannelModels(ctx, ch.ID) + if len(rows) != 2 { + t.Fatalf("开关关:弃用模型应在列,got %d", len(rows)) + } + if err := gw.SetFilterDeprecated(ctx, true); err != nil { + t.Fatalf("SetFilterDeprecated: %v", err) + } + rows, _ = gw.ChannelModels(ctx, ch.ID) + if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" { + t.Errorf("开关开:弃用模型应被过滤,got %+v", rows) + } + chs, _ := gw.Channels(ctx) + if len(chs) != 1 || chs[0].ModelCount != 1 { + t.Errorf("开关开:数量统计应同口径,got %+v", chs) + } +} + +// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额: +// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。 +func TestProbe403ContinuesToNextCandidate(t *testing.T) { + deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}} + tests := []struct { + name string + seq []probeResult + wantStatus string + }{ + {"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"}, + {"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"}, + {"全部候选 403", []probeResult{deny, deny}, "no_quota"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + models: []oci.GenAiModel{ + {Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}, + {Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"}, + }, + probeSeq: tt.seq, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}) + probed, err := gw.ProbeChannel(ctx, ch.ID) + if err != nil || probed.ProbeStatus != tt.wantStatus { + t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus) + } + }) + } +} + +// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。 +func TestChannelModelTest(t *testing.T) { + client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}} + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6}) + ctx := context.Background() + + if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil { + t.Error("缓存外模型应报错") + } + client.probeCode, client.probeErr = 403, stubServiceError{status: 403} + if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil { + t.Error("403 试调应报测试未通过") + } + var still model.AiChannel + gw.db.First(&still, ch.ID) + if still.ProbeStatus != "no_quota" || still.ProbeModel != "" { + t.Errorf("失败不应改动渠道: %+v", still) + } + client.probeCode, client.probeErr = 200, nil + fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct") + if err != nil { + t.Fatalf("TestChannelModel: %v", err) + } + if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" || + fresh.ProbeError != "" || fresh.FailCount != 0 { + t.Errorf("通过后应写验证模型并置可用: %+v", fresh) + } + // 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间 + cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()} + gw.db.Create(cache2) + fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4") + if err != nil || fresh2.ProbeModel != "xai.grok-4" { + t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err) + } +} + func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) { // 微调基座 400 换渠道重试成功,且不计入熔断失败 client := &gatewayStubClient{