Files
sub2api/backend/internal/service/openai_opencode_session.go
T
刘佳林 242907854d feat(opencode): add OpenCode platform with Zen and GO account types
Keep the internal platform ID opencode_go. Product surface is OpenCode
with account_mode zen|go, model-aware routing across Chat Completions,
Responses, and Anthropic Messages, and X-OpenCode-Session derived from
prompt_cache_key / metadata.user_id so prompt cache can hit.

Unmatched models fall back to /v1/chat/completions. Related to #6742.
2026-09-11 10:05:24 +08:00

173 lines
4.9 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"
)
// 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 resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string {
if c != nil && c.Request != nil {
if sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
return sessionID
}
if sessionID := explicitOpenAIHeaderSessionID(c); sessionID != "" {
return sessionID
}
if sessionID := ClaudeCodeSessionIDFromHeader(c); sessionID != "" {
return sessionID
}
}
for _, body := range bodies {
if sessionID := openCodeSessionIDFromPayload(body); sessionID != "" {
return sessionID
}
}
if sessionID := strings.TrimSpace(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 ""
}