Files
sub2api/backend/internal/service/openai_opencode_session.go
T

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