mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
221 lines
6.8 KiB
Go
221 lines
6.8 KiB
Go
package service
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const (
|
|
openCodeSessionHeader = "X-OpenCode-Session"
|
|
openCodeInboundBodyContextKey = "opencode_inbound_body"
|
|
|
|
// openCodeUpstreamUserAgent 是发往官方 OpenCode 上游的规范 User-Agent。
|
|
// opencode.ai 前置 Cloudflare 按客户端 UA 签名做 bot 拦截:Python-urllib 等
|
|
// 编程库 UA 会被 CF error code 1010 拒绝并返回 403。网关透传白名单放行
|
|
// user-agent,客户端自报身份原样到达上游会命中 WAF;该 403 再被计入账号
|
|
// 凭证类 strike,健康账号因此被自动禁用。取值沿用真实 opencode 客户端的
|
|
// UA 格式(opencode/<version>)。
|
|
openCodeUpstreamUserAgent = "opencode/1.0.0"
|
|
)
|
|
|
|
// rememberOpenCodeInboundBody keeps the client request body so protocol
|
|
// conversion (Responses↔Anthropic↔Chat Completions) can still recover
|
|
// prompt_cache_key / metadata.user_id after those fields are dropped.
|
|
func rememberOpenCodeInboundBody(c *gin.Context, body []byte) {
|
|
if c == nil || len(body) == 0 {
|
|
return
|
|
}
|
|
c.Set(openCodeInboundBodyContextKey, body)
|
|
}
|
|
|
|
func openCodeInboundBodies(c *gin.Context) [][]byte {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
raw, ok := c.Get(openCodeInboundBodyContextKey)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
body, ok := raw.([]byte)
|
|
if !ok || len(body) == 0 {
|
|
return nil
|
|
}
|
|
return [][]byte{body}
|
|
}
|
|
|
|
// applyOpenCodeSessionHeader sets x-opencode-session on outbound inference
|
|
// requests. OpenCode Go requires a per-conversation value from 2026-09-05
|
|
// (MissingSessionID). Prefer caller headers, then the documented body session
|
|
// fields (OpenAI prompt_cache_key / Anthropic metadata.user_id), then any
|
|
// already applied account override. A generated UUID is last-resort only for
|
|
// probes and clients that omit every stable identifier — a new UUID each turn
|
|
// would miss upstream prompt cache.
|
|
func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header, bodies ...[]byte) {
|
|
if account == nil || account.Type != AccountTypeAPIKey || headers == nil {
|
|
return
|
|
}
|
|
if !shouldSendOpenCodeSessionHeader(account, targetURL) {
|
|
return
|
|
}
|
|
|
|
payloads := append(openCodeInboundBodies(c), bodies...)
|
|
sessionID := resolveOpenCodeSessionID(c, headers, shouldGenerateOpenCodeSession(account, targetURL), payloads...)
|
|
if sessionID == "" {
|
|
return
|
|
}
|
|
for key := range headers {
|
|
if strings.EqualFold(key, openCodeSessionHeader) {
|
|
delete(headers, key)
|
|
}
|
|
}
|
|
headers.Set(openCodeSessionHeader, sessionID)
|
|
}
|
|
|
|
func shouldSendOpenCodeSessionHeader(account *Account, targetURL string) bool {
|
|
if account != nil && account.IsOpenCodeGoPlan() {
|
|
return true
|
|
}
|
|
return isOfficialOpenCodeHost(targetURL)
|
|
}
|
|
|
|
func shouldGenerateOpenCodeSession(account *Account, targetURL string) bool {
|
|
if account != nil && account.IsOpenCodeGoPlan() {
|
|
return true
|
|
}
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(parsed.Scheme, "https") &&
|
|
strings.EqualFold(parsed.Hostname(), "opencode.ai") &&
|
|
strings.Contains(parsed.Path, "/zen/go")
|
|
}
|
|
|
|
func isOfficialOpenCodeHost(targetURL string) bool {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai")
|
|
}
|
|
|
|
func isOfficialCommandCodeHost(targetURL string) bool {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "api.commandcode.ai")
|
|
}
|
|
|
|
// applyOpenCodeUpstreamUserAgent 将发往官方 OpenCode / Command Code 上游的出站
|
|
// User-Agent 收敛为规范客户端身份,覆盖客户端透传值与平台默认 UA。判定规则与
|
|
// x-opencode-session 同源:opencode 平台账号,或(任意平台账号)目标为官方主机。
|
|
// 必须先于 Account.ApplyHeaderOverrides 调用——账号 header_overrides 中显式
|
|
// 配置的 user-agent 仍拥有最终决定权。
|
|
func applyOpenCodeUpstreamUserAgent(account *Account, targetURL string, headers http.Header) {
|
|
if headers == nil {
|
|
return
|
|
}
|
|
|
|
userAgent := ""
|
|
switch {
|
|
case isOfficialCommandCodeHost(targetURL):
|
|
userAgent = CodexCanonicalUserAgent()
|
|
case account != nil && account.IsOpenCodeGo(), isOfficialOpenCodeHost(targetURL):
|
|
userAgent = openCodeUpstreamUserAgent
|
|
default:
|
|
return
|
|
}
|
|
if userAgent == "" {
|
|
return
|
|
}
|
|
|
|
// 先删任意大小写变体再写入:透传链路与覆写直写 map 可能残留非 canonical 键。
|
|
for key := range headers {
|
|
if strings.EqualFold(key, "User-Agent") {
|
|
delete(headers, key)
|
|
}
|
|
}
|
|
headers.Set("User-Agent", userAgent)
|
|
}
|
|
|
|
func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string {
|
|
if c != nil && c.Request != nil {
|
|
if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
if sessionID := sanitizeSessionID(explicitOpenAIHeaderSessionID(c)); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
if sessionID := sanitizeSessionID(ClaudeCodeSessionIDFromHeader(c)); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
}
|
|
for _, body := range bodies {
|
|
if sessionID := sanitizeSessionID(openCodeSessionIDFromPayload(body)); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
}
|
|
if sessionID := sanitizeSessionID(existingOpenCodeSessionHeader(headers)); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
if generate {
|
|
return uuid.NewString()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// openCodeSessionIDFromPayload reads the stable conversation id from documented
|
|
// request-body fields. Kimi Code / OpenAI clients send prompt_cache_key on
|
|
// Chat Completions and Responses; Anthropic clients send metadata.user_id on
|
|
// Messages. Neither field changes model behavior.
|
|
func openCodeSessionIDFromPayload(body []byte) string {
|
|
if len(body) == 0 {
|
|
return ""
|
|
}
|
|
view := openAIRequestPayloadView(body)
|
|
if sessionID := strings.TrimSpace(view.Get("prompt_cache_key").String()); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
return openCodeSessionIDFromMetadataUserID(view.Get("metadata.user_id").String())
|
|
}
|
|
|
|
func openCodeSessionIDFromMetadataUserID(userID string) string {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(userID, "{") {
|
|
if sessionID := strings.TrimSpace(gjson.Get(userID, "session_id").String()); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
}
|
|
return userID
|
|
}
|
|
|
|
func openCodeSessionHintBody(promptCacheKey string) []byte {
|
|
key := strings.TrimSpace(promptCacheKey)
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
return []byte(`{"prompt_cache_key":` + strconv.Quote(key) + `}`)
|
|
}
|
|
|
|
func existingOpenCodeSessionHeader(headers http.Header) string {
|
|
if headers == nil {
|
|
return ""
|
|
}
|
|
for key, values := range headers {
|
|
if strings.EqualFold(key, openCodeSessionHeader) && len(values) > 0 {
|
|
return values[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|