feat: add kimi a7m overlay workflow and remote43 validation

This commit is contained in:
phamnazage-jpg
2026-05-26 07:50:43 +08:00
parent 497e5d91b4
commit 83a05b4889
174 changed files with 3424 additions and 122 deletions

View File

@@ -4,6 +4,9 @@
它不是宿主原生插件,而是一个可被控制面读取的 `model_pack`,用于描述国产模型 provider 的默认接入模板、默认模型映射、默认套餐和导入约束。
当前 pack 也可以承载 provider 级 `host_overlays` 元数据。
这类 overlay 不是立即在线改宿主代码,而是把“某个 provider 在某个宿主版本上需要额外运行时补丁/兼容层”的工程信息纳入 pack 管理,供 preview/import 阶段直接暴露,并可由 CLI 执行器生成 patched 宿主构建目录。
当前目录现在同时包含:
- 真实可校验包:`pack.json``providers/deepseek.json``checksums.txt`
@@ -60,6 +63,18 @@ go run ./cmd/cli import-provider \
如果你要导入的不是这 10 个模板之一,而是一个全新的官方 provider那么仍然需要先补一个新的 provider manifest再做一键导入。
对已经声明 `host_overlays` 的 provider也可以直接用最小执行器生成 patched 宿主源码目录。例如对 `kimi-a7m` 命中的 `sub2api v0.1.129` overlay
```bash
go run ./cmd/cli apply-host-overlay \
--pack-dir ./packs/openai-cn-pack \
--provider-id kimi-a7m \
--host-version 0.1.129 \
--source-dir /tmp/sub2api-clean
```
命令会解析 provider manifest 中命中的 overlay复制 `--source-dir` 到新的输出目录,应用 `patch_path` 指向的补丁,并在输出目录写入 `.sub2api-cn-relay-manager-overlay.json` 元数据文件。
后续真实交付时,还可以继续扩展更多 provider
- `kimi.json`

View File

@@ -7,7 +7,10 @@
5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json
65e3a1a5e56889ddb0474a3b55294aceb6920fa72dcf6f2c56d3199462daa4cf providers/kimi-k2-thinking-official.json
a39de44fa68fcb5ee9c3ef38ed3bd5c30acd23cacd2f618d670de0bf9e7096e3 providers/deepseek-reasoner-official.json
e3da0745a14cb76f7275bfef90b40a6f652f4dce2efd95e44c27fe2e81f4eea5 pack.json
2db47989a9715464a34b00f7e322ceb1396f96617ddcfa7dee5bd3e7b262c17d providers/kimi-a7m.json
584991c1a5a3973bda9701ad15bb1c1b167038baa513cb67985708a65bdf6ca6 overlays/kimi-a7m-sub2api-v0.1.129.md
2b2597694ab03409360bf73de43a5cfcea0e26369c4ab18cc8552ff3278729aa overlays/kimi-a7m-sub2api-v0.1.129.patch
4d0069e7bb014b886d4b21fa9a2144fcf65e835f158eda1bb98e092efebd93f3 pack.json
eda16afc83e12055d3a41b5e37fd0923d3741b66da5af780bcea53ff34fa130e providers/step-3-5-flash-official.json
fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json
fdf7fa2e1ff4aa4f5dcd3f3ec2f55db11d6197625a467d6d0afa8a554a6ba6e6 providers/deepseek-chat-official.json

View File

@@ -0,0 +1,32 @@
# Kimi A7M / sub2api v0.1.129 Overlay
`overlay_id`: `sub2api-stock-v0129-kimi-a7m`
适用范围:
- 宿主:`sub2api`
- 版本:`0.1.129`
- provider`kimi-a7m`
触发背景:
- stock `weishaw/sub2api:0.1.129` 面对 `https://kimi.a7m.com.cn/v1` 时,`/v1/models` 可以命中 `kimi-k2.6`
- 但运行时 `/v1/chat/completions` 仍会误走到不兼容的 `Responses` 路径,最终返回 `502 upstream_error`
- 仅靠 relay-manager 控制面侧方案 C当前还不能把这条链路收敛到 `ready`
已验证的宿主补丁落点:
- `backend/internal/service/openai_apikey_responses_probe.go`
- `backend/internal/service/openai_gateway_chat_completions.go`
- `backend/internal/service/account_test_service.go`
参考证据:
- `artifacts/real-host-acceptance/20260525_local_v0129_kimi_a7m_from_hermes/22-patched-host-validation.json`
- `artifacts/real-host-acceptance/20260525_local_v0129_kimi_a7m_from_hermes/23-sub2api-host-patch-notes.md`
- `artifacts/real-host-acceptance/20260525_local_v0129_kimi_a7m_scheme_c_stockhost_rerun/21-summary.json`
当前用途:
- 由 pack/provider manifest 暴露给控制面,作为“该 provider 在该宿主版本上需要 overlay”的正式插件元数据
- 这一步只负责纳管,不直接修改宿主源码

View File

@@ -0,0 +1,356 @@
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index b9cd698a..3a58e022 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -555,14 +555,8 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
}
- // 账号已被探测为不支持 Responses如 DeepSeek/Kimi 等)时,丢出明确提示。
- // 账号本身可用(网关会走 CC 直转),仅测试入口需要补齐 CC SSE 处理逻辑。
- // TODO实现 CC 格式的账号测试路径(需专门的 CC SSE handler
if !openai_compat.ShouldUseResponsesAPI(account.Extra) {
- return s.sendErrorAndEnd(c,
- "账号已被探测为不支持 OpenAI Responses API如 DeepSeek/Kimi 等三方兼容上游),"+
- "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
- )
+ return s.testOpenAIRawChatCompletionsConnection(c, ctx, account, testModelID, prompt, normalizedBaseURL, authToken)
}
apiURL = buildOpenAIResponsesURL(normalizedBaseURL)
} else {
@@ -1321,6 +1315,133 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
}
}
+func (s *AccountTestService) testOpenAIRawChatCompletionsConnection(
+ c *gin.Context,
+ ctx context.Context,
+ account *Account,
+ modelID string,
+ prompt string,
+ normalizedBaseURL string,
+ authToken string,
+) error {
+ apiURL := buildOpenAIChatCompletionsURL(normalizedBaseURL)
+
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Writer.Flush()
+
+ payloadPrompt := strings.TrimSpace(prompt)
+ if payloadPrompt == "" {
+ payloadPrompt = "hi"
+ }
+ payloadBytes, _ := json.Marshal(map[string]any{
+ "model": modelID,
+ "messages": []map[string]any{
+ {
+ "role": "user",
+ "content": payloadPrompt,
+ },
+ },
+ "stream": true,
+ "stream_options": map[string]any{
+ "include_usage": true,
+ },
+ })
+
+ s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
+
+ req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
+ req.Header.Set("User-Agent", customUA)
+ }
+
+ proxyURL := ""
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+
+ resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
+ }
+
+ return s.processOpenAIChatCompletionsStream(c, resp.Body)
+}
+
+func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, body io.Reader) error {
+ reader := bufio.NewReader(body)
+ seenEvent := false
+
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF && seenEvent {
+ s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
+ return nil
+ }
+ if err == io.EOF {
+ return s.sendErrorAndEnd(c, "Stream ended before any chat completion event was received")
+ }
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
+ }
+
+ line = strings.TrimSpace(line)
+ if line == "" || !sseDataPrefix.MatchString(line) {
+ continue
+ }
+
+ jsonStr := sseDataPrefix.ReplaceAllString(line, "")
+ if jsonStr == "[DONE]" {
+ s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
+ return nil
+ }
+
+ seenEvent = true
+
+ var data map[string]any
+ if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+ continue
+ }
+
+ if errData, ok := data["error"].(map[string]any); ok {
+ if msg, ok := errData["message"].(string); ok && msg != "" {
+ return s.sendErrorAndEnd(c, msg)
+ }
+ return s.sendErrorAndEnd(c, "Unknown error")
+ }
+
+ choices, ok := data["choices"].([]any)
+ if !ok || len(choices) == 0 {
+ continue
+ }
+ firstChoice, ok := choices[0].(map[string]any)
+ if !ok {
+ continue
+ }
+ delta, ok := firstChoice["delta"].(map[string]any)
+ if !ok {
+ continue
+ }
+ if text, ok := delta["content"].(string); ok && text != "" {
+ s.sendEvent(c, TestEvent{Type: "content", Text: text})
+ }
+ }
+}
+
// testOpenAIImageAPIKey tests OpenAI image generation using an API Key account.
func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
authToken := account.GetOpenAIApiKey()
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index a4eb9252..6935fa6e 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -44,6 +44,28 @@ func openaiResponsesProbePayload(modelID string) []byte {
return body
}
+// openaiChatCompletionsProbePayload 是用于交叉验证的最小 Chat Completions 请求体。
+//
+// 当 /v1/responses 返回 403 这类模糊信号时,我们再探一次
+// /v1/chat/completions若 chat 端点可达,则说明该上游更可能是
+// chat-only OpenAI-compatible而不是完整支持 Responses 的实现。
+func openaiChatCompletionsProbePayload(modelID string) []byte {
+ if strings.TrimSpace(modelID) == "" {
+ modelID = openai.DefaultTestModel
+ }
+ body, _ := json.Marshal(map[string]any{
+ "model": modelID,
+ "messages": []map[string]any{
+ {
+ "role": "user",
+ "content": "hi",
+ },
+ },
+ "stream": false,
+ })
+ return body
+}
+
// ProbeOpenAIAPIKeyResponsesSupport 探测 OpenAI APIKey 账号上游是否支持
// /v1/responses 端点,并将结果持久化到 accounts.extra.openai_responses_supported。
//
@@ -51,6 +73,9 @@ func openaiResponsesProbePayload(modelID string) []byte {
//
// 探测策略(参见包文档 internal/pkg/openai_compat
// - 上游 404 / 405 → 不支持,写 false
+// - 上游 403 → 继续交叉探测 /v1/chat/completions
+// - - chat 端点可达(非 404/405→ 视为 chat-only upstream写 false
+// - - chat 端点不可确认 / 探测失败 → 保持旧语义,写 true
// - 上游 2xx / 其他 4xx401/422/400 等)/ 5xx → 支持,写 true
// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
// (后续请求仍按"现状即证据"默认走 Responses
@@ -116,6 +141,28 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
}()
supported := isResponsesEndpointSupportedByStatus(resp.StatusCode)
+ if resp.StatusCode == http.StatusForbidden {
+ chatStatus, chatErr := s.probeOpenAIAPIKeyChatCompletionsStatus(ctx, account)
+ if chatErr != nil {
+ logger.LegacyPrintf(
+ "service.openai_probe",
+ "probe_chat_crosscheck_failed: account_id=%d base_url=%s err=%v",
+ accountID,
+ normalizedBaseURL,
+ chatErr,
+ )
+ } else if isChatCompletionsEndpointSupportedByStatus(chatStatus) {
+ supported = false
+ logger.LegacyPrintf(
+ "service.openai_probe",
+ "probe_chat_crosscheck_chat_only: account_id=%d base_url=%s responses_status=%d chat_status=%d",
+ accountID,
+ normalizedBaseURL,
+ resp.StatusCode,
+ chatStatus,
+ )
+ }
+ }
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
openai_compat.ExtraKeyResponsesSupported: supported,
@@ -147,3 +194,59 @@ func isResponsesEndpointSupportedByStatus(status int) bool {
}
return true
}
+
+func isChatCompletionsEndpointSupportedByStatus(status int) bool {
+ switch status {
+ case http.StatusNotFound, http.StatusMethodNotAllowed:
+ return false
+ default:
+ return true
+ }
+}
+
+func (s *AccountTestService) probeOpenAIAPIKeyChatCompletionsStatus(ctx context.Context, account *Account) (int, error) {
+ if account == nil {
+ return 0, http.ErrNoLocation
+ }
+
+ apiKey := account.GetOpenAIApiKey()
+ if apiKey == "" {
+ return 0, http.ErrNoCookie
+ }
+ baseURL := account.GetOpenAIBaseURL()
+ if baseURL == "" {
+ baseURL = "https://api.openai.com"
+ }
+ normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
+ if err != nil {
+ return 0, err
+ }
+
+ probeURL := buildOpenAIChatCompletionsURL(normalizedBaseURL)
+ probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiChatCompletionsProbePayload("")))
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ proxyURL := ""
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+
+ resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
+ if err != nil {
+ return 0, err
+ }
+ defer func() {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
+ _ = resp.Body.Close()
+ }()
+
+ return resp.StatusCode, nil
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 84d85c74..fe6ff300 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -247,6 +247,17 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
+ if shouldFallbackResponsesCompatToRawChat(account, resp.StatusCode) {
+ s.markOpenAIResponsesUnsupported(ctx, account)
+ logger.L().Info("openai chat_completions: fallback responses->raw chat after custom upstream incompatibility signal",
+ zap.Int64("account_id", account.ID),
+ zap.String("account_name", account.Name),
+ zap.String("base_url", account.GetOpenAIBaseURL()),
+ zap.Int("responses_status", resp.StatusCode),
+ zap.String("upstream_message", upstreamMsg),
+ )
+ return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
+ }
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
@@ -316,6 +327,47 @@ func normalizeResponsesRequestServiceTier(req *apicompat.ResponsesRequest) {
req.ServiceTier = normalizedOpenAIServiceTierValue(req.ServiceTier)
}
+func shouldFallbackResponsesCompatToRawChat(account *Account, status int) bool {
+ if account == nil || account.Type != AccountTypeAPIKey {
+ return false
+ }
+ if strings.TrimSpace(account.GetOpenAIBaseURL()) == "" {
+ return false
+ }
+ switch status {
+ case http.StatusForbidden, http.StatusNotFound, http.StatusMethodNotAllowed:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *OpenAIGatewayService) markOpenAIResponsesUnsupported(ctx context.Context, account *Account) {
+ if account == nil || account.Type != AccountTypeAPIKey {
+ return
+ }
+
+ if account.Extra == nil {
+ account.Extra = map[string]any{}
+ }
+ if supported, ok := account.Extra[openai_compat.ExtraKeyResponsesSupported].(bool); ok && !supported {
+ return
+ }
+ account.Extra[openai_compat.ExtraKeyResponsesSupported] = false
+
+ if s == nil || s.accountRepo == nil {
+ return
+ }
+ if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
+ openai_compat.ExtraKeyResponsesSupported: false,
+ }); err != nil {
+ logger.L().Warn("openai chat_completions: persist responses unsupported flag failed",
+ zap.Int64("account_id", account.ID),
+ zap.Error(err),
+ )
+ }
+}
+
func normalizeResponsesBodyServiceTier(body []byte) ([]byte, string, error) {
if len(body) == 0 {
return body, "", nil

View File

@@ -1,6 +1,6 @@
{
"pack_id": "openai-cn-pack",
"version": "1.1.0",
"version": "1.1.3",
"vendor": "YourTeam",
"target_host": "sub2api",
"min_host_version": "0.1.126",

View File

@@ -0,0 +1,44 @@
{
"provider_id": "kimi-a7m",
"display_name": "Kimi A7M OpenAI Compatible",
"base_url": "https://kimi.a7m.com.cn/v1",
"platform": "openai",
"account_type": "apikey",
"force_disable_openai_responses_api": true,
"host_overlays": [
{
"overlay_id": "sub2api-stock-v0129-kimi-a7m",
"display_name": "sub2api stock v0.1.129 Kimi A7M overlay",
"target_host": "sub2api",
"min_host_version": "0.1.129",
"max_host_version": "0.1.129",
"apply_mode": "patch",
"patch_path": "overlays/kimi-a7m-sub2api-v0.1.129.patch",
"notes_path": "overlays/kimi-a7m-sub2api-v0.1.129.md",
"reason": "stock host still routes Kimi A7M chat traffic into an incompatible Responses path; runtime overlay or shim is still required"
}
],
"default_models": ["kimi-k2.6"],
"smoke_test_model": "kimi-k2.6",
"group_template": {
"name": "Kimi A7M 默认分组",
"rate_multiplier": 1.0
},
"channel_template": {
"name": "Kimi A7M 默认渠道",
"model_mapping": {
"kimi-k2.6": "kimi-k2.6"
}
},
"plan_template": {
"name": "Kimi A7M 默认套餐",
"price": 19.9,
"validity_days": 30,
"validity_unit": "day"
},
"import": {
"supports_multi_key": true,
"supports_strict": true,
"supports_partial": true
}
}