sc / main.go
LerinaOwO's picture
Upload 6 files
79d8a2e verified
Raw
History Blame Contribute Delete
48.5 kB
package main
import (
"bufio"
"bytes"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"mime/multipart"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// ==================== Config ====================
var (
scnetBase = "https://www.scnet.cn"
defaultModelID = 510
defaultModel = "deepseek-v4-pro"
apiKey string
scnetUsername string
scnetPassword string
maxSessions = 10
isLoggedIn bool
verbose bool
modelMap = map[string]int{
"deepseek-v4-pro": 510,
"deepseek-v4-flash": 520,
}
modelMapSearch = map[string]int{
"deepseek-v4-pro-search": 510,
"deepseek-v4-flash-search": 520,
}
rsaPubPEM = `-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALaXEnbjI6fjy+t9W9AiO/KS0q+b/OZF
S+7ykinLbiriUx9P8BcuuHnVbXNiZp5jW70eVGBtX4DhGUPzJa1YT/8CAwEAAQ==
-----END PUBLIC KEY-----`
)
// ==================== Cookie Pool ====================
const convPerCookie = 5
type CookieSet map[string]string
var (
cookiePool []CookieSet
cookieMu sync.RWMutex
)
func getCookieForIdx(idx int) CookieSet {
cookieMu.RLock()
defer cookieMu.RUnlock()
if idx < len(cookiePool) {
return cookiePool[idx]
}
if len(cookiePool) > 0 {
return cookiePool[0]
}
return CookieSet{"language": "zh"}
}
func getCookieString(idx int) string {
c := getCookieForIdx(idx)
parts := make([]string, 0, len(c))
for k, v := range c {
parts = append(parts, k+"="+v)
}
return strings.Join(parts, "; ")
}
// ==================== Cache ====================
type CacheEntry struct {
ConvID string `json:"conv_id"`
CookieIdx int `json:"cookie_idx"`
}
var cacheFile string
func init() {
cacheFile = defaultCacheFilePath()
}
func defaultCacheFilePath() string {
if v := os.Getenv("CACHE_FILE"); v != "" {
return v
}
// Hugging Face persistent storage, when enabled, is mounted at /data.
// Fall back to /tmp so the app can run on the default ephemeral Space disk.
if st, err := os.Stat("/data"); err == nil && st.IsDir() {
return filepath.Join("/data", "conversations.json")
}
return filepath.Join(os.TempDir(), "conversations.json")
}
func loadCache() map[string]CacheEntry {
data, err := os.ReadFile(cacheFile)
if err != nil {
return make(map[string]CacheEntry)
}
var cache map[string]CacheEntry
if json.Unmarshal(data, &cache) != nil {
return make(map[string]CacheEntry)
}
return cache
}
func saveCache(cache map[string]CacheEntry) {
// Trim to maxSessions
for len(cache) > maxSessions {
// Find oldest key
var oldestKey string
for k := range cache {
oldestKey = k
break
}
entry := cache[oldestKey]
delete(cache, oldestKey)
go deleteScnetConversation(entry.ConvID, entry.CookieIdx)
}
data, _ := json.MarshalIndent(cache, "", " ")
if dir := filepath.Dir(cacheFile); dir != "." && dir != "" {
_ = os.MkdirAll(dir, 0755)
}
if err := os.WriteFile(cacheFile, data, 0644); err != nil {
log.Printf("[CACHE] Failed to write %s: %v", cacheFile, err)
}
}
func hashUserMessages(messages []Message) string {
var parts []string
for _, m := range messages {
switch m.Role {
case "user":
if s := formatMessageContent(m.Content); s != "" {
parts = append(parts, s)
}
case "assistant":
s := formatMessageContent(m.Content)
tc := formatToolCallsText(m.ToolCalls)
if s != "" || tc != "" {
parts = append(parts, "assistant:"+s+tc)
}
}
}
if len(parts) <= 1 {
return ""
}
// 使用最后2条消息的哈希来查找缓存,而不是除最后一条外的所有消息
// 这样可以更好地支持多轮对话
if len(parts) >= 2 {
lastTwo := parts[len(parts)-2:]
raw, _ := json.Marshal(lastTwo)
h := md5.Sum(raw)
return hex.EncodeToString(h[:])[:16]
}
prev := parts[:len(parts)-1]
raw, _ := json.Marshal(prev)
h := md5.Sum(raw)
return hex.EncodeToString(h[:])[:16]
}
func hashForStore(messages []Message) string {
var parts []string
for _, m := range messages {
switch m.Role {
case "user":
if s := formatMessageContent(m.Content); s != "" {
parts = append(parts, s)
}
case "assistant":
s := formatMessageContent(m.Content)
tc := formatToolCallsText(m.ToolCalls)
if s != "" || tc != "" {
parts = append(parts, "assistant:"+s+tc)
}
}
}
if len(parts) == 0 {
return ""
}
raw, _ := json.Marshal(parts)
h := md5.Sum(raw)
return hex.EncodeToString(h[:])[:16]
}
func getAvailableCookieIdx(cache map[string]CacheEntry) int {
counts := make(map[int]int)
for _, entry := range cache {
counts[entry.CookieIdx]++
}
cookieMu.RLock()
poolSize := len(cookiePool)
cookieMu.RUnlock()
for i := 0; i < poolSize; i++ {
if counts[i] < convPerCookie {
return i
}
}
// Find min
minIdx := 0
minCount := counts[0]
for i := 1; i < poolSize; i++ {
if counts[i] < minCount {
minIdx = i
minCount = counts[i]
}
}
return minIdx
}
// ==================== Auth ====================
func encryptPassword(pw string) (string, error) {
block, _ := pem.Decode([]byte(rsaPubPEM))
if block == nil {
return "", fmt.Errorf("invalid PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", err
}
rsaPub := pub.(*rsa.PublicKey)
cipher, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, []byte(pw))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(cipher), nil
}
func scnetLogin() bool {
if scnetUsername == "" || scnetPassword == "" {
return false
}
log.Printf("[LOGIN] Logging in as '%s'...", scnetUsername)
encPW, err := encryptPassword(scnetPassword)
if err != nil {
log.Printf("[LOGIN] RSA error: %v", err)
return false
}
form := fmt.Sprintf("username=%s&password=%s&encrypted=true&ph=1", scnetUsername, encPW)
req, _ := http.NewRequest("POST", scnetBase+"/ac/api/auth/loginDesktopClient.action", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("[LOGIN] Error: %v", err)
return false
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
json.Unmarshal(body, &result)
if result.Code != "0" {
log.Printf("[LOGIN] Failed: %s", result.Msg)
return false
}
cookies := CookieSet{
"language": "zh",
"org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE": "zh_CN",
}
for _, v := range resp.Header.Values("Set-Cookie") {
kv := strings.SplitN(v, ";", 2)[0]
if idx := strings.Index(kv, "="); idx > 0 {
cookies[strings.TrimSpace(kv[:idx])] = strings.TrimSpace(kv[idx+1:])
}
}
cookies["SKIP_SESSION_UPDATE"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
cookieMu.Lock()
cookiePool = append(cookiePool, cookies)
cookieMu.Unlock()
isLoggedIn = true
return true
}
func fetchOneAnonymousCookie() CookieSet {
req, _ := http.NewRequest("GET", scnetBase+"/acx/chatbot/config/list", nil)
req.Header.Set("Accept", "*/*")
req.Header.Set("Referer", scnetBase+"/ui/chatbot/")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
cookies := CookieSet{
"language": "zh",
"org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE": "zh_CN",
}
for _, v := range resp.Header.Values("Set-Cookie") {
kv := strings.SplitN(v, ";", 2)[0]
if idx := strings.Index(kv, "="); idx > 0 {
cookies[strings.TrimSpace(kv[:idx])] = strings.TrimSpace(kv[idx+1:])
}
}
if _, ok := cookies["CHATBOT_ANONYMOUS_ID"]; ok {
return cookies
}
return nil
}
func fetchAnonymousCookies() {
needed := (maxSessions + convPerCookie - 1) / convPerCookie
for i := 0; i < needed; i++ {
c := fetchOneAnonymousCookie()
if c != nil {
cookieMu.Lock()
cookiePool = append(cookiePool, c)
cookieMu.Unlock()
}
}
}
// ==================== Cleanup ====================
func deleteScnetConversation(convID string, cookieIdx int) {
if convID == "" {
return
}
req, _ := http.NewRequest("DELETE", scnetBase+"/acx/chatbot/conversation/"+convID, nil)
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", scnetBase)
req.Header.Set("Referer", scnetBase+"/ui/chatbot/"+convID)
req.Header.Set("Cookie", getCookieString(cookieIdx))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
func deleteAllConversations() {
total := 0
cookieMu.RLock()
poolCopy := make([]CookieSet, len(cookiePool))
copy(poolCopy, cookiePool)
cookieMu.RUnlock()
for _, cookies := range poolCopy {
page := 1
for {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/acx/chatbot/conversation/list?page=%d&size=50&source=chat", scnetBase, page), nil)
req.Header.Set("Accept", "*/*")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Referer", scnetBase+"/ui/chatbot/")
parts := make([]string, 0, len(cookies))
for k, v := range cookies {
parts = append(parts, k+"="+v)
}
req.Header.Set("Cookie", strings.Join(parts, "; "))
resp, err := http.DefaultClient.Do(req)
if err != nil {
break
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data struct {
Data struct {
Records []struct {
ID string `json:"id"`
} `json:"records"`
} `json:"data"`
}
json.Unmarshal(body, &data)
if len(data.Data.Records) == 0 {
break
}
for _, r := range data.Data.Records {
if r.ID != "" {
delReq, _ := http.NewRequest("DELETE", scnetBase+"/acx/chatbot/conversation/"+r.ID, nil)
delReq.Header.Set("Accept", "*/*")
delReq.Header.Set("Origin", scnetBase)
delReq.Header.Set("Referer", scnetBase+"/ui/chatbot/"+r.ID)
delReq.Header.Set("Cookie", strings.Join(parts, "; "))
http.DefaultClient.Do(delReq)
total++
}
}
if len(data.Data.Records) < 50 {
break
}
page++
}
}
os.Remove(cacheFile)
}
// ==================== SSE ====================
type SSEChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []SSEChoice `json:"choices"`
}
type SSEChoice struct {
Index int `json:"index"`
Delta map[string]string `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
func makeSSEChunk(content, reasoning, model, chatID string, finishReason *string) string {
delta := map[string]string{}
if content != "" {
delta["content"] = content
}
if reasoning != "" {
delta["reasoning_content"] = reasoning
}
return makeSSEChunkWithDelta(delta, model, chatID, finishReason)
}
func makeSSERoleChunk(model, chatID string) string {
return makeSSEChunkWithDelta(map[string]string{"role": "assistant"}, model, chatID, nil)
}
func makeSSEChunkWithDelta(delta map[string]string, model, chatID string, finishReason *string) string {
if chatID == "" {
chatID = fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19]
}
if delta == nil {
delta = map[string]string{}
}
chunk := SSEChunk{
ID: chatID, Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: model,
Choices: []SSEChoice{{Index: 0, Delta: delta, FinishReason: finishReason}},
}
b, _ := json.Marshal(chunk)
return "data: " + string(b) + "\n\n"
}
func makeSSEDone() string { return "data: [DONE]\n\n" }
func makeSSEPrelude() string {
return ": prelude " + strings.Repeat("x", 2048) + "\n\n"
}
func randomID() string {
b := make([]byte, 9)
rand.Read(b)
return hex.EncodeToString(b)
}
func makeSSEToolCallChunk(toolCalls []ToolCall, model, chatID string, finishReason *string) string {
delta := map[string]interface{}{
"tool_calls": toolCalls,
}
if chatID == "" {
chatID = fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19]
}
chunk := map[string]interface{}{
"id": chatID,
"object": "chat.completion.chunk",
"created": time.Now().Unix(),
"model": model,
"choices": []map[string]interface{}{{
"index": 0,
"delta": delta,
"finish_reason": finishReason,
}},
}
b, _ := json.Marshal(chunk)
return "data: " + string(b) + "\n\n"
}
func injectToolsPrompt(systemContent string, tools []Tool) string {
if len(tools) == 0 {
return systemContent
}
var desc strings.Builder
for _, tool := range tools {
if tool.Function == nil {
continue
}
fn := tool.Function
desc.WriteString(fmt.Sprintf("### %s\n", fn.Name))
if fn.Description != "" {
desc.WriteString(fmt.Sprintf("Description: %s\n", fn.Description))
}
if fn.Parameters != nil {
paramJSON, _ := json.Marshal(fn.Parameters)
desc.WriteString(fmt.Sprintf("Parameters: %s\n", string(paramJSON)))
}
desc.WriteString("\n")
}
prompt := `You are an assistant that can use tools. Follow these rules STRICTLY:
1. If you need to use a tool, your ENTIRE response must be a single JSON object in this exact format:
{"tool_calls":[{"name":"TOOL_NAME","arguments":{"param":"value"}}]}
2. Do NOT include markdown code blocks, explanations, or any other text before or after the JSON.
3. If no tool is needed, answer normally and do NOT output any JSON.
Available tools:
` + desc.String() + `Examples:
User: What is the weather in Beijing?
Assistant: {"tool_calls":[{"name":"get_weather","arguments":{"location":"Beijing"}}]}
User: Hello
Assistant: Hello! How can I help you today?`
if systemContent != "" {
return systemContent + "\n\n" + prompt
}
return prompt
}
func parseToolCalls(content string) []ToolCall {
idx := strings.Index(content, "tool_calls")
if idx < 0 {
return nil
}
start := strings.LastIndex(content[:idx], "{")
if start < 0 {
return nil
}
depth := 0
end := -1
for i := start; i < len(content); i++ {
switch content[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = i + 1
}
}
if end > 0 {
break
}
}
if end < 0 {
return nil
}
raw := content[start:end]
var parsed struct {
ToolCalls []struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
raw = strings.ReplaceAll(raw, "'", "\"")
re := regexp.MustCompile(`(?:(\w+)\s*:)`)
raw = re.ReplaceAllString(raw, `"$1":`)
if err2 := json.Unmarshal([]byte(raw), &parsed); err2 != nil {
return nil
}
}
if len(parsed.ToolCalls) == 0 {
return nil
}
result := make([]ToolCall, len(parsed.ToolCalls))
for i, tc := range parsed.ToolCalls {
args := "{}"
switch v := tc.Arguments.(type) {
case string:
args = v
default:
if b, err := json.Marshal(v); err == nil {
args = string(b)
}
}
result[i] = ToolCall{
ID: fmt.Sprintf("call_%s_%d", randomID(), i),
Type: "function",
Function: ToolCallFunc{
Name: tc.Name,
Arguments: args,
},
}
}
return result
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, canFlush bool, payload string) {
w.Write([]byte(payload))
if canFlush {
flusher.Flush()
}
}
type thinkStreamState struct {
inThink bool
carry string
}
func partialTagSuffixLen(s, tag string) int {
limit := min(len(s), len(tag)-1)
for n := limit; n > 0; n-- {
if strings.HasSuffix(s, tag[:n]) {
return n
}
}
return 0
}
func feedThinkStream(raw string, state *thinkStreamState, emit func(content, reasoning string)) {
if raw == "" {
return
}
state.carry += raw
for {
if state.inThink {
if idx := strings.Index(state.carry, "</think>"); idx >= 0 {
if idx > 0 {
emit("", state.carry[:idx])
}
state.carry = state.carry[idx+len("</think>"):]
state.inThink = false
continue
}
keep := partialTagSuffixLen(state.carry, "</think>")
emitLen := len(state.carry) - keep
if emitLen > 0 {
emit("", state.carry[:emitLen])
state.carry = state.carry[emitLen:]
}
return
}
if idx := strings.Index(state.carry, "<think>"); idx >= 0 {
if idx > 0 {
emit(state.carry[:idx], "")
}
state.carry = state.carry[idx+len("<think>"):]
state.inThink = true
continue
}
keep := partialTagSuffixLen(state.carry, "<think>")
emitLen := len(state.carry) - keep
if emitLen > 0 {
emit(state.carry[:emitLen], "")
state.carry = state.carry[emitLen:]
}
return
}
}
func flushThinkStream(state *thinkStreamState, emit func(content, reasoning string)) {
if state.carry == "" {
return
}
if state.inThink {
emit("", state.carry)
} else {
emit(state.carry, "")
}
state.carry = ""
}
func streamScnetResponse(w http.ResponseWriter, conversationID, content string, modelID int, modelName, storeHash string, cookieIdx int, thinkingEnable bool, onlineEnable bool, textFile, imageFile []FileInfo, tools []Tool) string {
body := map[string]interface{}{
"conversationId": conversationID, "content": content,
"thinkingEnable": thinkingEnable, "onlineEnable": onlineEnable,
"modelId": modelID, "textFile": textFile, "imageFile": imageFile,
"autoRun": 0, "clusterId": "",
}
bodyJSON, _ := json.Marshal(body)
chatID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19]
cookies := getCookieForIdx(cookieIdx)
if verbose {
log.Printf("[SCNET] conv=%s model=%s", orDefault(conversationID, "(new)"), modelName)
anonID := cookies["CHATBOT_ANONYMOUS_ID"]
if len(anonID) > 16 {
anonID = anonID[:16]
}
log.Printf(" cookie=%s...", anonID)
}
flusher, canFlush := w.(http.Flusher)
writeSSE(w, flusher, canFlush, makeSSEPrelude())
writeSSE(w, flusher, canFlush, makeSSERoleChunk(modelName, chatID))
req, _ := http.NewRequest("POST", scnetBase+"/acx/chatbot/v1/chat/completion", bytes.NewReader(bodyJSON))
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", scnetBase)
req.Header.Set("Referer", scnetBase+"/ui/chatbot/")
req.Header.Set("Cookie", getCookieString(cookieIdx))
resp, err := http.DefaultClient.Do(req)
if err != nil {
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk(fmt.Sprintf("[Error] %v", err), "", modelName, chatID, &stop))
writeSSE(w, flusher, canFlush, makeSSEDone())
return ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
errBody, _ := io.ReadAll(resp.Body)
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk(fmt.Sprintf("[Error] %d: %s", resp.StatusCode, string(errBody[:min(len(errBody), 200)])), "", modelName, chatID, &stop))
writeSSE(w, flusher, canFlush, makeSSEDone())
return ""
}
newConvID := ""
buffer := ""
reader := bufio.NewReader(resp.Body)
thinkState := thinkStreamState{}
hasTools := len(tools) > 0
var toolCallBuffer strings.Builder
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
buffer += line
}
for {
idx := strings.Index(buffer, "\n")
if idx < 0 {
break
}
originalLine := buffer[:idx]
buffer = buffer[idx+1:]
trimmed := strings.TrimSpace(originalLine)
if trimmed == "" || strings.HasPrefix(trimmed, ":") || !strings.HasPrefix(trimmed, "data:") {
continue
}
dataStr := strings.TrimSpace(trimmed[5:])
if dataStr == "[DONE]" {
flushThinkStream(&thinkState, func(content, reasoning string) {
if hasTools {
if content != "" {
toolCallBuffer.WriteString(content)
}
if reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil))
}
} else {
if content != "" || reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil))
}
}
})
if hasTools && toolCallBuffer.Len() > 0 {
bufContent := toolCallBuffer.String()
if tc := parseToolCalls(bufContent); tc != nil {
stop := "tool_calls"
writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop))
} else {
writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil))
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
} else if !hasTools {
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
writeSSE(w, flusher, canFlush, makeSSEDone())
return newConvID
}
var data map[string]interface{}
if json.Unmarshal([]byte(dataStr), &data) != nil {
continue
}
if verbose {
log.Printf("[RAW] %s", dataStr[:min(len(dataStr), 500)])
}
if cid, ok := data["conversationId"].(string); ok && cid != "" && newConvID == "" {
newConvID = cid
if verbose {
log.Printf("[SCNET] Got conversationId: %s", cid)
}
}
if ct, ok := data["contentType"].(string); ok && ct == "1002" {
flushThinkStream(&thinkState, func(content, reasoning string) {
if hasTools {
if content != "" {
toolCallBuffer.WriteString(content)
}
if reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil))
}
} else {
if content != "" || reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil))
}
}
})
if hasTools && toolCallBuffer.Len() > 0 {
bufContent := toolCallBuffer.String()
if tc := parseToolCalls(bufContent); tc != nil {
stop := "tool_calls"
writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop))
} else {
writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil))
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
} else if !hasTools {
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
writeSSE(w, flusher, canFlush, makeSSEDone())
if newConvID != "" && storeHash != "" {
cache := loadCache()
cache[storeHash] = CacheEntry{ConvID: newConvID, CookieIdx: cookieIdx}
saveCache(cache)
if verbose {
log.Printf("[CACHE] Saved: %s → %s (cookie %d)", storeHash, newConvID, cookieIdx)
}
}
return newConvID
}
textContent := ""
thinkingContent := ""
if choices, ok := data["choices"].([]interface{}); ok {
for _, c := range choices {
if choice, ok := c.(map[string]interface{}); ok {
if delta, ok := choice["delta"].(map[string]interface{}); ok {
if ct, ok := delta["content"].(string); ok {
textContent = ct
}
if th, ok := delta["reasoning_content"].(string); ok {
thinkingContent = th
}
if th, ok := delta["thinking"].(string); ok && thinkingContent == "" {
thinkingContent = th
}
}
}
}
}
if textContent == "" {
if raw, ok := data["content"].(string); ok && raw != "" && raw != "[done]" {
textContent = raw
}
}
if thinkingContent == "" {
if th, ok := data["thinking"].(string); ok && th != "" {
thinkingContent = th
}
}
if thinkingContent == "" {
if th, ok := data["reasoning_content"].(string); ok && th != "" {
thinkingContent = th
}
}
if textContent == "" || thinkingContent == "" {
if d, ok := data["data"].(map[string]interface{}); ok {
if textContent == "" {
if raw, ok := d["content"].(string); ok && raw != "" {
textContent = raw
}
}
if thinkingContent == "" {
if th, ok := d["thinking"].(string); ok && th != "" {
thinkingContent = th
}
}
if thinkingContent == "" {
if th, ok := d["reasoning_content"].(string); ok && th != "" {
thinkingContent = th
}
}
}
}
if textContent != "" || thinkingContent != "" {
if thinkingContent != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk("", thinkingContent, modelName, chatID, nil))
}
if textContent != "" {
feedThinkStream(textContent, &thinkState, func(content, reasoning string) {
if hasTools {
if content != "" {
toolCallBuffer.WriteString(content)
}
if reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil))
}
} else {
if content != "" || reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil))
}
}
})
}
}
}
if err != nil {
break
}
}
flushThinkStream(&thinkState, func(content, reasoning string) {
if hasTools {
if content != "" {
toolCallBuffer.WriteString(content)
}
if reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil))
}
} else {
if content != "" || reasoning != "" {
writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil))
}
}
})
if hasTools && toolCallBuffer.Len() > 0 {
bufContent := toolCallBuffer.String()
if tc := parseToolCalls(bufContent); tc != nil {
stop := "tool_calls"
writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop))
} else {
writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil))
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
} else if !hasTools {
stop := "stop"
writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop))
}
writeSSE(w, flusher, canFlush, makeSSEDone())
if newConvID != "" && storeHash != "" {
cache := loadCache()
cache[storeHash] = CacheEntry{ConvID: newConvID, CookieIdx: cookieIdx}
saveCache(cache)
if verbose {
log.Printf("[CACHE] Saved: %s → %s (cookie %d)", storeHash, newConvID, cookieIdx)
}
}
return newConvID
}
// ==================== Handlers ====================
const (
MaxFileSize = 50000 // 50KB max file size
MaxFileCount = 5 // Max 5 files per request
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Type string `json:"type"`
}
type ChatRequest struct {
Messages []Message `json:"messages"`
Model string `json:"model"`
ModelID interface{} `json:"modelId"`
Stream bool `json:"stream"`
OnlineEnable bool `json:"online_enable"`
WebSearch bool `json:"web_search"`
Tools []Tool `json:"tools"`
TextFile []FileInfo `json:"textFile"`
ImageFile []FileInfo `json:"imageFile"`
}
type Message struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type Tool struct {
Type string `json:"type"`
Function *ToolFunction `json:"function,omitempty"`
}
type ToolFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters interface{} `json:"parameters,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunc `json:"function"`
}
type ToolCallFunc struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
func formatMessageContent(content interface{}) string {
switch v := content.(type) {
case string:
return v
case []interface{}:
parts := []string{}
for _, block := range v {
b, ok := block.(map[string]interface{})
if !ok {
continue
}
switch b["type"] {
case "text":
if t, ok := b["text"].(string); ok {
parts = append(parts, t)
}
case "tool_use":
name, _ := b["name"].(string)
input, _ := json.Marshal(b["input"])
parts = append(parts, fmt.Sprintf("工具调用: %s\n参数: %s", name, string(input)))
case "tool_result":
id, _ := b["tool_use_id"].(string)
var c string
switch cv := b["content"].(type) {
case string:
c = cv
default:
cb, _ := json.Marshal(cv)
c = string(cb)
}
parts = append(parts, fmt.Sprintf("工具调用结果 (%s):\n%s", id, c))
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func formatToolCallsText(toolCalls []ToolCall) string {
if len(toolCalls) == 0 {
return ""
}
parts := []string{}
for _, tc := range toolCalls {
parts = append(parts, fmt.Sprintf("工具调用: %s\n参数: %s", tc.Function.Name, tc.Function.Arguments))
}
return strings.Join(parts, "\n")
}
// Scnet content 字段最大限制(保守估计,scnet 限制约 5000 字符)
const scnetContentLimit = 10000
// extractFilesFromContent 从 content 数组中提取过大的文本块,自动上传到 OSS 并转为 textFile
func extractFilesFromContent(content interface{}, cookieIdx int) (string, []FileInfo) {
var files []FileInfo
switch v := content.(type) {
case string:
if len(v) > scnetContentLimit {
// 整个 content 太大,尝试上传
if info, err := uploadTextToOSS(v, "content.txt", cookieIdx); err == nil {
files = append(files, info)
return "", files
}
}
return v, nil
case []interface{}:
var shortParts []string
for _, block := range v {
b, ok := block.(map[string]interface{})
if !ok {
continue
}
if b["type"] != "text" {
continue
}
text, _ := b["text"].(string)
if text == "" {
continue
}
// 检测是否有文件名前缀(如 "temp_file_xxx.txt\n...")
lines := strings.SplitN(text, "\n", 2)
fileName := ""
fileContent := text
if len(lines) == 2 && (strings.HasSuffix(lines[0], ".txt") || strings.HasSuffix(lines[0], ".md") || strings.HasSuffix(lines[0], ".json") || strings.HasSuffix(lines[0], ".csv")) {
// 第行看起来像文件名
parts := strings.Split(lines[0], "/")
fileName = parts[len(parts)-1]
fileContent = lines[1]
}
if len(fileContent) > scnetContentLimit {
// 内容太大,上传到 OSS
name := fileName
if name == "" {
name = "content.txt"
}
if info, err := uploadTextToOSS(fileContent, name, cookieIdx); err == nil {
files = append(files, info)
if verbose {
if fileName != "" {
log.Printf("[AUTO-UPLOAD] Extracted large text block as file: %s (%d bytes)", name, len(fileContent))
} else {
log.Printf("[AUTO-UPLOAD] Content too large (%d bytes), uploaded as file: %s", len(fileContent), name)
}
}
continue
}
}
shortParts = append(shortParts, text)
}
return strings.Join(shortParts, "\n"), files
default:
return "", nil
}
}
// uploadTextToOSS 将文本内容上传到阿里云 OSS
func uploadTextToOSS(content, fileName string, cookieIdx int) (FileInfo, error) {
// 获取上传签名
signReq, _ := http.NewRequest("GET", scnetBase+"/acx/chatbot/file/sso/form/signature", nil)
signReq.Header.Set("Accept", "*/*")
signReq.Header.Set("Referer", scnetBase+"/ui/chatbot/")
signReq.Header.Set("Cookie", getCookieString(cookieIdx))
signResp, err := http.DefaultClient.Do(signReq)
if err != nil {
return FileInfo{}, err
}
defer signResp.Body.Close()
var signResult struct {
Code string `json:"code"`
Data struct {
AccessID string `json:"accessid"`
Policy string `json:"policy"`
Signature string `json:"signature"`
Dir string `json:"dir"`
Host string `json:"host"`
AccessControl string `json:"accessControl"`
} `json:"data"`
}
if err := json.NewDecoder(signResp.Body).Decode(&signResult); err != nil || signResult.Code != "0" {
return FileInfo{}, fmt.Errorf("failed to parse signature")
}
// 上传到 OSS
ext := filepath.Ext(fileName)
if ext == "" {
ext = ".txt"
}
objectKey := signResult.Data.Dir + "/" + fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s%d", fileName, time.Now().UnixNano())))) + ext
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("key", objectKey)
mw.WriteField("policy", signResult.Data.Policy)
mw.WriteField("OSSAccessKeyId", signResult.Data.AccessID)
mw.WriteField("signature", signResult.Data.Signature)
mw.WriteField("success_action_status", "200")
mw.WriteField("x-oss-object-acl", signResult.Data.AccessControl)
part, _ := mw.CreateFormFile("file", fileName)
part.Write([]byte(content))
mw.Close()
ossReq, _ := http.NewRequest("POST", signResult.Data.Host, &buf)
ossReq.Header.Set("Content-Type", mw.FormDataContentType())
ossResp, err := http.DefaultClient.Do(ossReq)
if err != nil {
return FileInfo{}, err
}
defer ossResp.Body.Close()
if ossResp.StatusCode != 200 {
return FileInfo{}, fmt.Errorf("OSS upload failed: %d", ossResp.StatusCode)
}
return FileInfo{
Name: fileName,
Path: signResult.Data.Host + "/" + objectKey,
Size: int64(len(content)),
Type: "text/plain",
}, nil
}
func handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", 405)
return
}
// Auth
if apiKey != "" {
auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if auth != apiKey {
http.Error(w, `{"error":{"message":"Invalid API key","type":"authentication_error"}}`, 401)
return
}
}
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":{"message":"Invalid JSON"}}`, 400)
return
}
// 检查文件数量限制
totalFiles := len(req.TextFile) + len(req.ImageFile)
if totalFiles > MaxFileCount {
http.Error(w, fmt.Sprintf(`{"error":{"message":"Too many files. Max %d files per request"}}`, MaxFileCount), 400)
return
}
// Model
modelID := defaultModelID
if req.ModelID != nil {
if f, ok := req.ModelID.(float64); ok {
modelID = int(f)
}
}
if req.Model != "" {
if id, ok := modelMapSearch[req.Model]; ok {
modelID = id
} else if id, ok := modelMap[req.Model]; ok {
modelID = id
}
}
modelName := req.Model
if modelName == "" {
modelName = defaultModel
}
// Search requires login
onlineEnable := req.OnlineEnable || req.WebSearch
if !onlineEnable {
for _, t := range req.Tools {
if t.Type == "web_search" {
onlineEnable = true
break
}
}
}
if strings.HasSuffix(modelName, "-search") {
if !isLoggedIn {
http.Error(w, `{"error":{"message":"Search requires login. Set SCNET_USERNAME/SCNET_PASSWORD."}}`, 403)
return
}
onlineEnable = true
}
// Extract system + user messages
systemContent := ""
for _, msg := range req.Messages {
if msg.Role == "system" {
if s := formatMessageContent(msg.Content); s != "" {
systemContent = s
}
}
}
userContent := ""
var userMsgContent interface{}
for i := len(req.Messages) - 1; i >= 0; i-- {
msg := req.Messages[i]
if msg.Role == "user" || msg.Role == "tool" {
userContent = formatMessageContent(msg.Content)
userMsgContent = msg.Content
if userContent != "" {
break
}
}
}
if userContent == "" {
http.Error(w, `{"error":{"message":"No user message found"}}`, 400)
return
}
// 自动提取过大的 content 为 textFile
tempCache := loadCache()
tempCookieIdx := getAvailableCookieIdx(tempCache)
autoContent, autoExtractedFiles := extractFilesFromContent(userMsgContent, tempCookieIdx)
if len(autoExtractedFiles) > 0 {
userContent = autoContent
req.TextFile = append(req.TextFile, autoExtractedFiles...)
if verbose {
log.Printf("[AUTO-UPLOAD] Extracted %d file(s) from content", len(autoExtractedFiles))
}
}
// Conversation matching
lookupHash := hashUserMessages(req.Messages)
storeHash := hashForStore(req.Messages)
cache := loadCache()
conversationID := ""
cookieIdx := 0
// 尝试查找缓存的会话
if lookupHash != "" {
if entry, ok := cache[lookupHash]; ok {
conversationID = entry.ConvID
cookieIdx = entry.CookieIdx
}
}
// 如果缓存未命中,且消息数量 > 2(多轮对话),尝试查找最近的会话
if conversationID == "" && len(req.Messages) > 2 {
// 查找最近保存的会话(按存储顺序)
var latestEntry CacheEntry
found := false
for _, v := range cache {
latestEntry = v
found = true
}
if found {
conversationID = latestEntry.ConvID
cookieIdx = latestEntry.CookieIdx
if verbose {
log.Printf("[CACHE] Reusing latest conversation: %s", conversationID)
}
}
}
if conversationID == "" {
cookieIdx = getAvailableCookieIdx(cache)
}
// System prompt only for new conversations
if conversationID == "" {
systemContent = injectToolsPrompt(systemContent, req.Tools)
if systemContent != "" {
userContent = systemContent + "\n\n" + userContent
}
}
// Cache miss with history: send full context
if conversationID == "" && len(req.Messages) > 2 {
contextParts := []string{}
for _, msg := range req.Messages[:len(req.Messages)-1] {
switch msg.Role {
case "system":
continue
case "user":
if s := formatMessageContent(msg.Content); s != "" {
contextParts = append(contextParts, "用户: "+s)
}
case "assistant":
s := formatMessageContent(msg.Content)
tc := formatToolCallsText(msg.ToolCalls)
if s != "" && tc != "" {
contextParts = append(contextParts, "助手: "+s+"\n"+tc)
} else if s != "" {
contextParts = append(contextParts, "助手: "+s)
} else if tc != "" {
contextParts = append(contextParts, "助手: "+tc)
}
case "tool":
if s := formatMessageContent(msg.Content); s != "" {
id := msg.ToolCallID
if id == "" {
id = "unknown"
}
contextParts = append(contextParts, fmt.Sprintf("工具调用结果 (%s): %s", id, s))
}
}
}
if len(contextParts) > 0 {
userContent = "以下是之前的对话历史:\n" + strings.Join(contextParts, "\n") + "\n\n" + userContent
}
}
// Log
if verbose {
log.Printf("[REQUEST] model=%s stream=%v online=%v | msgs=%d", modelName, req.Stream, onlineEnable, len(req.Messages))
if systemContent != "" {
s := systemContent
if len(s) > 80 {
s = s[:80] + "..."
}
log.Printf(" System: %s", s)
}
for i, msg := range req.Messages {
s := formatMessageContent(msg.Content)
if len(s) > 80 {
s = s[:80] + "..."
}
extra := ""
if len(msg.ToolCalls) > 0 {
extra = fmt.Sprintf(" [tool_calls=%d]", len(msg.ToolCalls))
}
if msg.ToolCallID != "" {
extra = fmt.Sprintf(" [tool_call_id=%s]", msg.ToolCallID)
}
log.Printf(" [%d] %s%s: %s", i, msg.Role, extra, s)
}
log.Printf(" userContent: %s", truncate(userContent, 120))
}
if verbose {
lookupDisplay := lookupHash
if lookupDisplay == "" {
lookupDisplay = "(first)"
}
hitMiss := "MISS"
if conversationID != "" {
hitMiss = "HIT"
}
log.Printf(" Cache: %s → %s | store=%s cookie=%d", lookupDisplay, hitMiss, storeHash, cookieIdx)
}
if req.Stream {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
newConvID := streamScnetResponse(w, conversationID, userContent, modelID, modelName, storeHash, cookieIdx, true, onlineEnable, req.TextFile, req.ImageFile, req.Tools)
if newConvID != "" {
w.Header().Set("X-Conversation-Id", newConvID)
}
} else {
var buf bytes.Buffer
newConvID := streamScnetResponse(&capture{buf: &buf}, conversationID, userContent, modelID, modelName, storeHash, cookieIdx, true, onlineEnable, req.TextFile, req.ImageFile, req.Tools)
fullContent := ""
fullReasoning := ""
var fullToolCalls []ToolCall
for _, line := range strings.Split(buf.String(), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data: ") || strings.HasPrefix(line, "data: [DONE]") {
continue
}
var chunk map[string]interface{}
if json.Unmarshal([]byte(line[6:]), &chunk) != nil {
continue
}
if choices, ok := chunk["choices"].([]interface{}); ok {
for _, c := range choices {
if choice, ok := c.(map[string]interface{}); ok {
if delta, ok := choice["delta"].(map[string]interface{}); ok {
if content, ok := delta["content"].(string); ok {
fullContent += content
}
if reasoning, ok := delta["reasoning_content"].(string); ok {
fullReasoning += reasoning
}
if tcRaw, ok := delta["tool_calls"]; ok {
tcJSON, _ := json.Marshal(tcRaw)
var tcs []ToolCall
if json.Unmarshal(tcJSON, &tcs) == nil {
fullToolCalls = append(fullToolCalls, tcs...)
}
}
}
}
}
}
}
if len(fullToolCalls) == 0 && len(req.Tools) > 0 {
fullToolCalls = parseToolCalls(fullContent)
if fullToolCalls != nil {
fullContent = ""
}
}
msg := map[string]interface{}{"role": "assistant", "content": fullContent}
if fullReasoning != "" {
msg["reasoning_content"] = fullReasoning
}
finishReason := "stop"
if len(fullToolCalls) > 0 {
finishReason = "tool_calls"
msg["tool_calls"] = fullToolCalls
}
w.Header().Set("Content-Type", "application/json")
// 返回 conversationId 在响应头中,方便客户端后续请求使用
if newConvID != "" {
w.Header().Set("X-Conversation-Id", newConvID)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19], "object": "chat.completion",
"created": time.Now().Unix(), "model": modelName,
"choices": []map[string]interface{}{{"index": 0, "message": msg, "finish_reason": finishReason}},
"usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"conversation_id": newConvID,
})
}
}
type capture struct {
buf *bytes.Buffer
}
func (c *capture) Header() http.Header { return http.Header{} }
func (c *capture) Write(b []byte) (int, error) { return c.buf.Write(b) }
func (c *capture) WriteHeader(statusCode int) {}
func handleModels(w http.ResponseWriter, r *http.Request) {
allModels := make(map[string]int)
for k, v := range modelMap {
allModels[k] = v
}
if isLoggedIn {
for k, v := range modelMapSearch {
allModels[k] = v
}
}
models := []map[string]interface{}{}
for name, mid := range allModels {
models = append(models, map[string]interface{}{
"id": name, "object": "model", "created": 1700000000, "owned_by": "scnet",
"metadata": map[string]int{"modelId": mid},
})
}
sort.Slice(models, func(i, j int) bool { return models[i]["id"].(string) < models[j]["id"].(string) })
json.NewEncoder(w).Encode(map[string]interface{}{"object": "list", "data": models})
}
func handleHome(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
models := []string{"deepseek-v4-pro", "deepseek-v4-flash"}
if isLoggedIn {
models = append(models, "deepseek-v4-pro-search", "deepseek-v4-flash-search")
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"name": "SCNet OpenAI Compatible API Proxy",
"status": "ok",
"endpoints": map[string]string{
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"health": "/healthz",
},
"models": models,
})
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func withCommonHeaders(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Expose-Headers", "X-Conversation-Id")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return true
}
return false
}
func router(w http.ResponseWriter, r *http.Request) {
if withCommonHeaders(w, r) {
return
}
switch {
case r.URL.Path == "/" && r.Method == "GET":
handleHome(w, r)
case (r.URL.Path == "/healthz" || r.URL.Path == "/health") && r.Method == "GET":
handleHealth(w, r)
case r.URL.Path == "/v1/chat/completions" && r.Method == "POST":
handleChat(w, r)
case r.URL.Path == "/v1/models" && r.Method == "GET":
handleModels(w, r)
default:
http.NotFound(w, r)
}
}
// ==================== Main ====================
func loadDotEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if len(val) >= 2 && (val[0] == '"' && val[len(val)-1] == '"' || val[0] == '\'' && val[len(val)-1] == '\'') {
val = val[1 : len(val)-1]
}
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
func main() {
loadDotEnv()
cacheFile = defaultCacheFilePath()
apiKey = os.Getenv("API_KEY")
scnetUsername = os.Getenv("SCNET_USERNAME")
scnetPassword = os.Getenv("SCNET_PASSWORD")
if v := os.Getenv("SCNET_MAX_SESSIONS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxSessions = n
}
}
verbose = strings.EqualFold(os.Getenv("VERBOSE"), "true") || os.Getenv("VERBOSE") == "1"
// Login or fetch anonymous cookies
if !scnetLogin() {
fetchAnonymousCookies()
}
if isTruthyEnv("SCNET_CLEAN_ON_START") {
deleteAllConversations()
}
printBanner()
cookieMu.RLock()
poolSize := len(cookiePool)
cookieMu.RUnlock()
mode := "anonymous"
if isLoggedIn {
mode = "logged-in"
}
log.Printf("[INIT] Mode: %s, %d cookies", mode, poolSize)
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
if !isFalseyEnv("SCNET_CLEAN_ON_SHUTDOWN") {
log.Println("[SHUTDOWN] Cleaning up...")
deleteAllConversations()
}
os.Exit(0)
}()
http.HandleFunc("/", router)
host := os.Getenv("HOST")
if host == "" {
host = "0.0.0.0"
}
port := os.Getenv("PORT")
if port == "" {
port = "7860"
}
addr := net.JoinHostPort(host, port)
log.Printf("Running on http://%s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
}
}
func isTruthyEnv(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func isFalseyEnv(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return v == "0" || v == "false" || v == "no" || v == "off"
}
func printBanner() {
fmt.Println()
fmt.Println(" SCNet → OpenAI Compatible API Proxy")
fmt.Println()
// 鉴权信息
if apiKey != "" {
fmt.Printf(" API Key: %s\n", apiKey)
} else {
fmt.Println(" API Key: (无鉴权)")
}
// 账号信息
if scnetUsername != "" {
fmt.Printf(" 账号: %s\n", scnetUsername)
} else {
fmt.Println(" 账号: (匿名模式)")
}
fmt.Println()
// 调用端点
fmt.Println(" 端点:")
fmt.Println(" POST /v1/chat/completions — 聊天补全")
fmt.Println(" GET /v1/models — 模型列表")
fmt.Println()
// 鉴权方式
fmt.Println(" 鉴权:")
if apiKey != "" {
fmt.Println(" Authorization: Bearer <API_KEY>")
} else {
fmt.Println(" 无需鉴权")
}
fmt.Println()
// 可用模型
fmt.Println(" 模型:")
if isLoggedIn {
fmt.Println(" deepseek-v4-pro — DeepSeek V4 Pro")
fmt.Println(" deepseek-v4-flash — DeepSeek V4 Flash")
fmt.Println(" deepseek-v4-pro-search — DeepSeek V4 Pro + 联网搜索")
fmt.Println(" deepseek-v4-flash-search — DeepSeek V4 Flash + 联网搜索")
} else {
fmt.Println(" deepseek-v4-pro — DeepSeek V4 Pro")
fmt.Println(" deepseek-v4-flash — DeepSeek V4 Flash")
}
fmt.Println()
// 调用示例
fmt.Println(" 示例:")
fmt.Printf(` curl http://localhost:%s/v1/chat/completions \
`, orDefault(os.Getenv("PORT"), "7860"))
if apiKey != "" {
fmt.Printf(` -H "Authorization: Bearer %s" \`, apiKey)
fmt.Println()
}
fmt.Println(` -H "Content-Type: application/json" \`)
fmt.Println(` -d '{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"你好"}]}'`)
fmt.Println()
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}