feat: AI 服务商接口兼容 HTTP (#223)
This commit is contained in:
@@ -50,7 +50,7 @@ ai:
|
|||||||
# 留空时 Admin 正常启动,但不能保存密钥或测试 AI 连接。
|
# 留空时 Admin 正常启动,但不能保存密钥或测试 AI 连接。
|
||||||
secrets_path: ""
|
secrets_path: ""
|
||||||
|
|
||||||
# 默认只允许公网 HTTPS 服务。确需访问私有模型端点时,必须由部署人员
|
# 公网 AI 服务支持 HTTP 和 HTTPS;HTTP 不提供传输加密。确需访问私有模型端点时,必须由部署人员
|
||||||
# 在这里或 CMAUTOBUY_AI_ALLOWED_HOSTS 中按主机名显式允许,网页不能修改。
|
# 在这里或 CMAUTOBUY_AI_ALLOWED_HOSTS 中按主机名显式允许,网页不能修改。
|
||||||
allowed_hosts: []
|
allowed_hosts: []
|
||||||
|
|
||||||
|
|||||||
@@ -328,8 +328,12 @@ func ParseConfidenceThresholdBPS(raw string) (int, error) {
|
|||||||
|
|
||||||
func (p AIEndpointPolicy) ValidateSyntax(raw string) (string, error) {
|
func (p AIEndpointPolicy) ValidateSyntax(raw string) (string, error) {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTPS 地址"}
|
return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTP 或 HTTPS 地址"}
|
||||||
|
}
|
||||||
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", &validationError{field: "base_url", message: "Base URL 只支持 HTTP 或 HTTPS"}
|
||||||
}
|
}
|
||||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
return "", &validationError{field: "base_url", message: "Base URL 不能包含账号密码、查询参数或片段"}
|
return "", &validationError{field: "base_url", message: "Base URL 不能包含账号密码、查询参数或片段"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -57,23 +58,56 @@ func TestSaveAIProviderConfig_新增后可查询并与审计同事务(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) {
|
func TestAIEndpointPolicy_兼容HTTP并阻止凭据和内网地址(t *testing.T) {
|
||||||
policy := NewAIEndpointPolicy(nil)
|
policy := NewAIEndpointPolicy(nil)
|
||||||
for _, raw := range []string{
|
for _, raw := range []string{
|
||||||
"http://api.example.com/v1", "https://user:pass@api.example.com/v1",
|
"ftp://api.example.com/v1", "http://user:pass@api.example.com/v1", "https://user:pass@api.example.com/v1",
|
||||||
"https://127.0.0.1/v1", "https://169.254.169.254/latest", "https://localhost/v1",
|
"http://api.example.com/v1?token=fake", "https://api.example.com/v1#fragment",
|
||||||
|
"http://127.0.0.1/v1", "https://127.0.0.1/v1", "http://169.254.169.254/latest",
|
||||||
|
"https://169.254.169.254/latest", "http://localhost/v1", "https://localhost/v1",
|
||||||
} {
|
} {
|
||||||
if _, err := policy.ValidateSyntax(raw); err == nil {
|
if _, err := policy.ValidateSyntax(raw); err == nil {
|
||||||
t.Errorf("危险地址 %q 应被拒绝", raw)
|
t.Errorf("危险地址 %q 应被拒绝", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
got, err := policy.ValidateSyntax("https://api.example.com/v1/")
|
for raw, want := range map[string]string{
|
||||||
if err != nil || got != "https://api.example.com/v1" {
|
"http://api.example.com/v1/": "http://api.example.com/v1",
|
||||||
t.Fatalf("公网 HTTPS 地址应通过并去掉尾斜杠: %q %v", got, err)
|
"HTTPS://api.example.com/v1/": "https://api.example.com/v1",
|
||||||
|
} {
|
||||||
|
got, err := policy.ValidateSyntax(raw)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("公网 HTTP/HTTPS 地址应通过并规范化: raw=%q got=%q err=%v", raw, got, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
allowed := NewAIEndpointPolicy([]string{"10.0.0.8"})
|
allowed := NewAIEndpointPolicy([]string{"10.0.0.8"})
|
||||||
if _, err := allowed.ValidateSyntax("https://10.0.0.8/v1"); err != nil {
|
for _, raw := range []string{"http://10.0.0.8/v1", "https://10.0.0.8/v1"} {
|
||||||
t.Fatalf("部署允许的私有端点应通过: %v", err)
|
if _, err := allowed.ValidateSyntax(raw); err != nil {
|
||||||
|
t.Errorf("现有部署允许列表中的私有 HTTP/HTTPS 端点应通过: %q %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeAIHTTPClient_可调用允许列表中的HTTP端点(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Errorf("请求路径 = %q", request.URL.Path)
|
||||||
|
}
|
||||||
|
if request.Header.Get("Authorization") != "Bearer fake-http-key" {
|
||||||
|
t.Errorf("Authorization 未按既有方式发送")
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
policy := NewAIEndpointPolicy([]string{"127.0.0.1"})
|
||||||
|
if err := policy.ValidateResolved(context.Background(), server.URL+"/v1"); err != nil {
|
||||||
|
t.Fatalf("测试级允许列表中的 HTTP 地址应通过完整校验: %v", err)
|
||||||
|
}
|
||||||
|
client := NewSafeAIHTTPClient(policy, time.Second)
|
||||||
|
if err := callAIHealthCheck(context.Background(), client, model.AIProviderConfig{
|
||||||
|
BaseURL: server.URL + "/v1", Model: "test-model",
|
||||||
|
}, "fake-http-key"); err != nil {
|
||||||
|
t.Fatalf("HTTP OpenAI 兼容端点调用失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<label for="ai-name">服务商</label>
|
<label for="ai-name">服务商</label>
|
||||||
<input id="ai-name" name="name" maxlength="191" required placeholder="例如:主模型">
|
<input id="ai-name" name="name" maxlength="191" required placeholder="例如:主模型">
|
||||||
<label for="ai-base-url">Base URL</label>
|
<label for="ai-base-url">Base URL</label>
|
||||||
<input id="ai-base-url" name="base_url" type="url" required placeholder="https://api.example.com/v1">
|
<input id="ai-base-url" name="base_url" type="url" required placeholder="http://api.example.com/v1">
|
||||||
<label for="ai-model">模型</label>
|
<label for="ai-model">模型</label>
|
||||||
<input id="ai-model" name="model" maxlength="191" required placeholder="模型名称">
|
<input id="ai-model" name="model" maxlength="191" required placeholder="模型名称">
|
||||||
<label for="ai-timeout">超时</label>
|
<label for="ai-timeout">超时</label>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
|
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
|
||||||
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
|
||||||
{{if .SecretStoreError}}<p class="missing" role="alert">密钥存储不可用:{{.SecretStoreError}}</p>{{end}}
|
{{if .SecretStoreError}}<p class="missing" role="alert">密钥存储不可用:{{.SecretStoreError}}</p>{{end}}
|
||||||
<p class="hint">API Key 只写入部署指定的独立 600 权限密钥文件。页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。</p>
|
<p class="hint">Base URL 支持 HTTP 和 HTTPS;HTTP 会明文传输 API Key 和匹配请求,请只在可信网络使用。API Key 只写入部署指定的独立 600 权限密钥文件,页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。</p>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="ai-config-table">
|
<table class="ai-config-table">
|
||||||
|
|||||||
@@ -259,9 +259,10 @@ AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API
|
|||||||
仓库、`data/` 或 release 目录,生产建议为 `/etc/cmautobuy/ai-secrets.yaml` 且权限为
|
仓库、`data/` 或 release 目录,生产建议为 `/etc/cmautobuy/ai-secrets.yaml` 且权限为
|
||||||
`600`。页面只得到“是否配置”和固定掩码尾号。
|
`600`。页面只得到“是否配置”和固定掩码尾号。
|
||||||
|
|
||||||
外部模型调用位于独立 HTTP 客户端边界:只允许 HTTPS,拒绝 URL 内凭据、环回、链路
|
外部模型调用位于独立 HTTP 客户端边界:Base URL 允许 HTTP 和 HTTPS,拒绝其他协议、
|
||||||
本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级
|
URL 内凭据、环回、链路本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行
|
||||||
`allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。
|
相同检查。HTTP 不提供传输加密,只应在可信网络使用。部署级 `allowed_hosts` 是私有模型
|
||||||
|
端点的唯一例外入口,网页不能修改,也没有单独的 HTTP 开关。
|
||||||
|
|
||||||
规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。
|
规格匹配按“确定性规则 → 有限候选 → 模型选择 → 服务端硬校验 → 事务写入”的顺序执行。
|
||||||
模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。
|
模型只看到商品标题、SYB 规格文本和后端生成的候选短编号,不能生成真实 PDD 选项键。
|
||||||
|
|||||||
@@ -998,7 +998,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
|
|
||||||
## 15. AI 服务商配置(MySQL v20)
|
## 15. AI 服务商配置(MySQL v20)
|
||||||
|
|
||||||
`ai_provider_configs` 只保存非敏感配置:服务商名称、HTTPS Base URL、模型、超时、最大
|
`ai_provider_configs` 只保存非敏感配置:服务商名称、HTTP/HTTPS Base URL、模型、超时、最大
|
||||||
并发、自动写入置信度基点、启用状态和最近连接测试摘要。`active_slot` 是生成列并带唯一
|
并发、自动写入置信度基点、启用状态和最近连接测试摘要。`active_slot` 是生成列并带唯一
|
||||||
索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会
|
索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会
|
||||||
停用并把测试状态重置为 `pending`,必须重新测试后才能启用。
|
停用并把测试状态重置为 `pending`,必须重新测试后才能启用。
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ CMAutoBuyAdmin/
|
|||||||
- 密钥文件使用同目录临时文件、`fsync`、原子替换和 `600` 权限,失败时旧文件保持可用。
|
- 密钥文件使用同目录临时文件、`fsync`、原子替换和 `600` 权限,失败时旧文件保持可用。
|
||||||
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
|
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
|
||||||
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
|
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
|
||||||
|
- Base URL 可使用 HTTP 或 HTTPS,但 HTTP 不提供传输加密,API Key 和匹配请求会以明文经过网络,只应在可信网络使用。
|
||||||
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
||||||
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
|
- 规格匹配请求只包含商品标题、规格文本和候选短编号,不包含订单号、店铺账号、用户、地址或 Client 信息。
|
||||||
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
|
- 模型返回值必须经过严格 JSON 结构、候选白名单、颜色冲突、额外维度、置信度和上下文版本校验;模型自报置信度不能替代硬门禁。
|
||||||
|
|||||||
Reference in New Issue
Block a user