mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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.
173 lines
4.9 KiB
Go
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 ""
|
|
}
|