Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
188 KiB
JSON
1 line
188 KiB
JSON
{"repo": "6kmfi6hp/opencode2api", "files": {"main.go": {"9e4bd061efd2e0f272585317dc6e508e26e64b4c": {"t": 1786068606.056282, "n": 169896, "h": "f54a88df456f810f", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n)\n\nvar httpClient = &http.Client{\n\tTimeout: 300 * time.Second,\n\tTransport: &http.Transport{\n\t\tMaxIdleConns: 100,\n\t\tMaxIdleConnsPerHost: 20,\n\t\tIdleConnTimeout: 90 * time.Second,\n\t},\n}\n\nvar (\n\tversion = \"v0.3.7\"\n\tcommit = \"none\"\n\tdate = \"unknown\"\n)\n\nfunc versionString() string {\n\treturn fmt.Sprintf(\"opencode2api %s (commit=%s, date=%s)\", version, commit, date)\n}\n\n// ======================== SOCKS5 代理 ========================\n\ntype Socks5Proxy struct {\n\tAddr string `json:\"addr\"`\n\tUsername string `json:\"username,omitempty\"`\n\tPassword string `json:\"password,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\nfunc socks5Dial(proxy Socks5Proxy) func(ctx context.Context, network, addr string) (net.Conn, error) {\n\treturn func(ctx context.Context, network, target string) (net.Conn, error) {\n\t\tconn, err := net.DialTimeout(\"tcp\", proxy.Addr, 10*time.Second)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"socks5 connect to %s: %w\", proxy.Addr, err)\n\t\t}\n\t\tdeadline := time.Now().Add(15 * time.Second)\n\t\tconn.SetDeadline(deadline)\n\n\t\t// 认证方法协商\n\t\tauth := byte(0x00) // no auth\n\t\tif proxy.Username != \"\" {\n\t\t\tauth = 0x02 // username/password\n\t\t}\n\t\tif _, err := conn.Write([]byte{0x05, 0x01, auth}); err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5 handshake write: %w\", err)\n\t\t}\n\t\tbuf := make([]byte, 2)\n\t\tif _, err := io.ReadFull(conn, buf); err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5 handshake read: %w\", err)\n\t\t}\n\t\tif buf[0] != 0x05 {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5: not socks5 protocol\")\n\t\t}\n\n\t\t// 用户名/密码认证\n\t\tif buf[1] == 0x02 {\n\t\t\tif proxy.Username == \"\" {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: server requires auth but no credentials\")\n\t\t\t}\n\t\t\tulen := len(proxy.Username)\n\t\t\tplen := len(proxy.Password)\n\t\t\tauthBuf := make([]byte, 3+ulen+plen)\n\t\t\tauthBuf[0] = 0x01\n\t\t\tauthBuf[1] = byte(ulen)\n\t\t\tcopy(authBuf[2:], proxy.Username)\n\t\t\tauthBuf[2+ulen] = byte(plen)\n\t\t\tcopy(authBuf[3+ulen:], proxy.Password)\n\t\t\tif _, err := conn.Write(authBuf); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5 auth write: %w\", err)\n\t\t\t}\n\t\t\tauthResp := make([]byte, 2)\n\t\t\tif _, err := io.ReadFull(conn, authResp); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5 auth read: %w\", err)\n\t\t\t}\n\t\t\tif authResp[1] != 0x00 {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: auth failed\")\n\t\t\t}\n\t\t} else if buf[1] != 0x00 {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5: unsupported auth method 0x%02x\", buf[1])\n\t\t}\n\n\t\t// CONNECT 请求\n\t\thost, portStr, err := net.SplitHostPort(target)\n\t\tif err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5: invalid target %s: %w\", target, err)\n\t\t}\n\t\tport := 0\n\t\tfmt.Sscanf(portStr, \"%d\", &port)\n\n\t\treq := []byte{0x05, 0x01, 0x00} // VER, CMD=CONNECT, RSV\n\t\tip := net.ParseIP(host)\n\t\tif ip != nil {\n\t\t\tif ip4 := ip.To4(); ip4 != nil {\n\t\t\t\treq = append(req, 0x01) // IPv4\n\t\t\t\treq = append(req, ip4...)\n\t\t\t} else {\n\t\t\t\treq = append(req, 0x04) // IPv6\n\t\t\t\treq = append(req, ip.To16()...)\n\t\t\t}\n\t\t} else {\n\t\t\tif len(host) > 255 {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: hostname too long\")\n\t\t\t}\n\t\t\treq = append(req, 0x03) // Domain\n\t\t\treq = append(req, byte(len(host)))\n\t\t\treq = append(req, []byte(host)...)\n\t\t}\n\t\treq = append(req, byte(port>>8), byte(port))\n\n\t\tif _, err := conn.Write(req); err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5 connect write: %w\", err)\n\t\t}\n\n\t\t// 读取响应\n\t\tresp := make([]byte, 4)\n\t\tif _, err := io.ReadFull(conn, resp); err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5 connect read: %w\", err)\n\t\t}\n\t\tif resp[1] != 0x00 {\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5: connect failed, status 0x%02x\", resp[1])\n\t\t}\n\n\t\t// 读取绑定地址\n\t\tswitch resp[3] {\n\t\tcase 0x01: // IPv4\n\t\t\tif _, err := io.ReadFull(conn, make([]byte, 4+2)); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: read bind ipv4: %w\", err)\n\t\t\t}\n\t\tcase 0x03: // Domain\n\t\t\tdlen := make([]byte, 1)\n\t\t\tif _, err := io.ReadFull(conn, dlen); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: read bind domain len: %w\", err)\n\t\t\t}\n\t\t\tif _, err := io.ReadFull(conn, make([]byte, int(dlen[0])+2)); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: read bind domain: %w\", err)\n\t\t\t}\n\t\tcase 0x04: // IPv6\n\t\t\tif _, err := io.ReadFull(conn, make([]byte, 16+2)); err != nil {\n\t\t\t\tconn.Close()\n\t\t\t\treturn nil, fmt.Errorf(\"socks5: read bind ipv6: %w\", err)\n\t\t\t}\n\t\tdefault:\n\t\t\tconn.Close()\n\t\t\treturn nil, fmt.Errorf(\"socks5: unknown address type 0x%02x\", resp[3])\n\t\t}\n\n\t\tconn.SetDeadline(time.Time{})\n\t\treturn conn, nil\n\t}\n}\n\nvar (\n\tsocks5Proxies []Socks5Proxy\n\tactiveSocks5 string // 启用的代理 Addr,空表示直连,__round_robin__ 表示轮询\n\tsocks5PaidDirect bool // true=带 key/付费直连;false/缺省=全部走代理\n\tsocks5Mu sync.RWMutex\n)\n\nconst socks5RR = \"__round_robin__\"\n\nvar socks5RRIndex uint32\n\nvar (\n\tsocks5Client *http.Client // 缓存的 SOCKS5 客户端\n\tsocks5ClientAddr string // 缓存对应的代理地址\n)\n\nfunc getHTTPClient() *http.Client {\n\tsocks5Mu.RLock()\n\tdefer socks5Mu.RUnlock()\n\n\tif activeSocks5 == \"\" {\n\t\treturn httpClient\n\t}\n\n\tvar proxy Socks5Proxy\n\tvar useRR bool\n\n\tif activeSocks5 == socks5RR {\n\t\tif len(socks5Proxies) == 0 {\n\t\t\treturn httpClient\n\t\t}\n\t\tidx := atomic.AddUint32(&socks5RRIndex, 1) % uint32(len(socks5Proxies))\n\t\tproxy = socks5Proxies[idx]\n\t\tuseRR = true\n\t} else {\n\t\tif socks5Client != nil && socks5ClientAddr == activeSocks5 {\n\t\t\treturn socks5Client\n\t\t}\n\n\t\tvar found bool\n\t\tfor i := range socks5Proxies {\n\t\t\tif socks5Proxies[i].Addr == activeSocks5 {\n\t\t\t\tproxy = socks5Proxies[i]\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\treturn httpClient\n\t\t}\n\t}\n\n\tdial := socks5Dial(proxy)\n\tclient := &http.Client{\n\t\tTimeout: 300 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tDialContext: dial,\n\t\t\tMaxIdleConns: 100,\n\t\t\tMaxIdleConnsPerHost: 20,\n\t\t\tIdleConnTimeout: 90 * time.Second,\n\t\t},\n\t}\n\n\tif !useRR {\n\t\tsocks5Client = client\n\t\tsocks5ClientAddr = activeSocks5\n\t}\n\treturn client\n}\n\n// getHTTPClientForTier 按认证层级选择 HTTP 客户端。\n// 默认(socks5_paid_direct 未填或 false):只要配置了 active_socks5,付费/带 key 与 public 都走代理。\n// socks5_paid_direct=true 时恢复旧行为:付费层直连,仅免费层走代理。\nfunc getHTTPClientForTier(tier TierType) *http.Client {\n\tif tier == TierPaid && getSocks5PaidDirect() {\n\t\treturn httpClient\n\t}\n\treturn getHTTPClient()\n}\n\nfunc getSocks5PaidDirect() bool {\n\tsocks5Mu.RLock()\n\tdefer socks5Mu.RUnlock()\n\treturn socks5PaidDirect\n}\n\n// ======================== 随机 ID ========================\n\nfunc randomString(n int) string {\n\tconst letters = \"abcdefghijklmnopqrstuvwxyz0123456789\"\n\tb := make([]byte, n)\n\trand.Read(b)\n\tfor i := range b {\n\t\tb[i] = letters[b[i]%byte(len(letters))]\n\t}\n\treturn string(b)\n}\n\nfunc randomHex(n int) string {\n\tconst hex = \"0123456789abcdef\"\n\tb := make([]byte, n)\n\trand.Read(b)\n\tfor i := range b {\n\t\tb[i] = hex[b[i]%byte(len(hex))]\n\t}\n\treturn string(b)\n}\n\n// ======================== OpenCode 会话 ========================\n\nvar (\n\tocSessionID string\n\tocProjectID string\n\tocClientVer string\n\tocOnce sync.Once\n\trequestCount atomic.Int64\n)\n\nfunc fetchOCVersion() string {\n\treq, _ := http.NewRequest(\"GET\", \"https://registry.npmjs.org/opencode-ai/latest\", nil)\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tresp, err := getHTTPClient().Do(req)\n\tif err != nil {\n\t\treturn \"1.15.3\"\n\t}\n\tdefer resp.Body.Close()\n\tbody, _ := io.ReadAll(resp.Body)\n\tvar info struct {\n\t\tVersion string `json:\"version\"`\n\t}\n\tif json.Unmarshal(body, &info) == nil && info.Version != \"\" {\n\t\treturn info.Version\n\t}\n\treturn \"1.15.3\"\n}\n\nfunc initOCSession() {\n\tocOnce.Do(func() {\n\t\tocClientVer = fetchOCVersion()\n\t\tocSessionID = \"ses_\" + randomString(24)\n\t\tocProjectID = randomHex(40)\n\t\tslog.Info(\"opencode version\", \"version\", ocClientVer)\n\t\tslog.Info(\"session initialized\", \"session_id\", ocSessionID)\n\t\tslog.Info(\"project initialized\", \"project_id\", ocProjectID)\n\t})\n}\n\nfunc refreshOCSession() {\n\tocClientVer = fetchOCVersion()\n\tocSessionID = \"ses_\" + randomString(24)\n\tocProjectID = randomHex(40)\n\tslog.Info(\"session refreshed\", \"version\", ocClientVer, \"session_id\", ocSessionID)\n\t// 重置 Once 以便后续 initOCSession 调用直接通过\n\tocOnce = sync.Once{}\n}\n\n// ======================== 模型 ========================\n\ntype ModelInfo struct {\n\tID string `json:\"id\"`\n\tObject string `json:\"object\"`\n\tCreated int64 `json:\"created\"`\n\tOwnedBy string `json:\"owned_by\"`\n}\n\nvar (\n\tmodelsCache []ModelInfo\n\tgoModelsCache []ModelInfo\n\tmodelMu sync.RWMutex\n\tmodelsLoaded bool\n)\n\nfunc fetchModels() ([]ModelInfo, error) {\n\treq, _ := http.NewRequest(\"GET\", \"https://opencode.ai/zen/v1/models\", nil)\n\treq.Header.Set(\"Authorization\", \"Bearer public\")\n\treq.Header.Set(\"x-opencode-session\", ocSessionID)\n\tresp, err := getHTTPClient().Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tbody, _ := io.ReadAll(resp.Body)\n\tvar result struct {\n\t\tData []struct {\n\t\t\tID string `json:\"id\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, err\n\t}\n\tvar models []ModelInfo\n\tnow := time.Now().Unix()\n\tfor _, m := range result.Data {\n\t\tmodels = append(models, ModelInfo{ID: m.ID, Object: \"model\", Created: now, OwnedBy: \"opencode\"})\n\t}\n\treturn models, nil\n}\n\nfunc fetchGoModels() ([]ModelInfo, error) {\n\treq, _ := http.NewRequest(\"GET\", \"https://opencode.ai/zen/go/v1/models\", nil)\n\treq.Header.Set(\"Authorization\", \"Bearer public\")\n\treq.Header.Set(\"x-opencode-session\", ocSessionID)\n\tresp, err := getHTTPClient().Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tbody, _ := io.ReadAll(resp.Body)\n\tvar result struct {\n\t\tData []struct {\n\t\t\tID string `json:\"id\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, err\n\t}\n\tvar models []ModelInfo\n\tnow := time.Now().Unix()\n\tfor _, m := range result.Data {\n\t\tmodels = append(models, ModelInfo{ID: m.ID, Object: \"model\", Created: now, OwnedBy: \"opencode\"})\n\t}\n\treturn models, nil\n}\n\nfunc containsModelWithID(models []ModelInfo, modelID string) bool {\n\tfor _, model := range models {\n\t\tif model.ID == modelID {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isModelInGoCatalog(modelID string) bool {\n\tmodelMu.RLock()\n\tdefer modelMu.RUnlock()\n\treturn containsModelWithID(goModelsCache, modelID)\n}\n\nfunc isGoCatalogOnlyModel(modelID string) bool {\n\tmodelMu.RLock()\n\tdefer modelMu.RUnlock()\n\treturn containsModelWithID(goModelsCache, modelID) && !containsModelWithID(modelsCache, modelID)\n}\n\nfunc getModelIDs() []string {\n\tmodelMu.RLock()\n\tdefer modelMu.RUnlock()\n\tids := make([]string, len(modelsCache))\n\tfor i, m := range modelsCache {\n\t\tids[i] = m.ID\n\t}\n\treturn ids\n}\n\nfunc getGoModelIDs() []string {\n\tmodelMu.RLock()\n\tdefer modelMu.RUnlock()\n\tids := make([]string, len(goModelsCache))\n\tfor i, m := range goModelsCache {\n\t\tids[i] = m.ID\n\t}\n\treturn ids\n}\n\n// isNonRetryableUpstreamError reports billing/credits failures that must not\n// trigger retries.\nfunc isNonRetryableUpstreamError(status int, body []byte) bool {\n\tif status != http.StatusUnauthorized && status != http.StatusPaymentRequired && status != http.StatusForbidden {\n\t\treturn false\n\t}\n\tvar payload struct {\n\t\tType string `json:\"type\"`\n\t\tError struct {\n\t\t\tType string `json:\"type\"`\n\t\t\tMessage string `json:\"message\"`\n\t\t} `json:\"error\"`\n\t}\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\treturn false\n\t}\n\terrType := strings.ToLower(strings.TrimSpace(payload.Error.Type))\n\tif errType == \"\" {\n\t\terrType = strings.ToLower(strings.TrimSpace(payload.Type))\n\t}\n\tif errType == \"creditserror\" || errType == \"insufficient_quota\" || errType == \"billing_error\" {\n\t\treturn true\n\t}\n\tmsg := strings.ToLower(payload.Error.Message)\n\treturn strings.Contains(msg, \"insufficient balance\") || strings.Contains(msg, \"insufficient credits\")\n}\n\n// startModelRefresh 定时刷新模型列表(每 10 分钟)\nfunc startModelRefresh() {\n\tgo func() {\n\t\tticker := time.NewTicker(10 * time.Minute)\n\t\tdefer ticker.Stop()\n\t\tfor range ticker.C {\n\t\t\tfetched, err := fetchModels()\n\t\t\tif err == nil && len(fetched) > 0 {\n\t\t\t\tmodelMu.Lock()\n\t\t\t\tmodelsCache = fetched\n\t\t\t\tmodelsLoaded = true\n\t\t\t\tmodelMu.Unlock()\n\t\t\t\tslog.Info(\"models auto-refreshed\", \"count\", len(fetched))\n\t\t\t} else if err != nil {\n\t\t\t\tslog.Error(\"free models refresh failed\", \"error\", err)\n\t\t\t}\n\n\t\t\tgoFetched, goErr := fetchGoModels()\n\t\t\tif goErr == nil && len(goFetched) > 0 {\n\t\t\t\tmodelMu.Lock()\n\t\t\t\tgoModelsCache = goFetched\n\t\t\t\tmodelMu.Unlock()\n\t\t\t\tslog.Info(\"go catalog auto-refreshed\", \"count\", len(goFetched))\n\t\t\t} else if goErr != nil {\n\t\t\t\tslog.Error(\"go catalog refresh failed\", \"error\", goErr)\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// ======================== 结构化日志 ========================\n\ntype contextKey string\n\nconst reqIDKey contextKey = \"request_id\"\n\nfunc getReqID(ctx context.Context) string {\n\tif id, ok := ctx.Value(reqIDKey).(string); ok {\n\t\treturn id\n\t}\n\treturn \"\"\n}\n\n// ======================== 配置 ========================\n\nvar (\n\tport string\n\tconfigPath = \"config.json\"\n\tmodelAlias = map[string]string{}\n\treasoningEffortMap = map[string]string{}\n\tforceDisableThinking bool\n\tdebugMode bool\n\tconfigMu sync.RWMutex\n\tstoredResponses = map[string]StoredResponseState{}\n\tstoredResponsesMu sync.RWMutex\n)\n\n// ======================== 管理面板认证 ========================\n\nvar (\n\tadminPassword string\n\tsessions = map[string]struct{}{}\n\tsessionsMu sync.Mutex\n)\n\nfunc requireAuth(next http.HandlerFunc) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tif adminPassword == \"\" {\n\t\t\tnext(w, r)\n\t\t\treturn\n\t\t}\n\t\tcookie, err := r.Cookie(\"session\")\n\t\tif err != nil || cookie.Value == \"\" {\n\t\t\thttp.Redirect(w, r, \"/login\", http.StatusFound)\n\t\t\treturn\n\t\t}\n\t\tsessionsMu.Lock()\n\t\t_, ok := sessions[cookie.Value]\n\t\tsessionsMu.Unlock()\n\t\tif !ok {\n\t\t\thttp.Redirect(w, r, \"/login\", http.StatusFound)\n\t\t\treturn\n\t\t}\n\t\tnext(w, r)\n\t}\n}\n\nfunc generateToken() (string, error) {\n\tb := make([]byte, 32)\n\tif _, err := rand.Read(b); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b), nil\n}\n\nfunc loginHandler(w http.ResponseWriter, r *http.Request) {\n\tif adminPassword == \"\" {\n\t\thttp.Redirect(w, r, \"/\", http.StatusFound)\n\t\treturn\n\t}\n\tif r.Method == http.MethodPost {\n\t\tif err := r.ParseForm(); err != nil {\n\t\t\trenderLoginPage(w, \"表单解析失败\")\n\t\t\treturn\n\t\t}\n\t\tif r.FormValue(\"password\") != adminPassword {\n\t\t\trenderLoginPage(w, \"密码错误\")\n\t\t\treturn\n\t\t}\n\t\ttoken, err := generateToken()\n\t\tif err != nil {\n\t\t\trenderLoginPage(w, \"创建会话失败\")\n\t\t\treturn\n\t\t}\n\t\tsessionsMu.Lock()\n\t\tsessions[token] = struct{}{}\n\t\tsessionsMu.Unlock()\n\t\thttp.SetCookie(w, &http.Cookie{Name: \"session\", Value: token, Path: \"/\", HttpOnly: true})\n\t\thttp.Redirect(w, r, \"/\", http.StatusFound)\n\t\treturn\n\t}\n\trenderLoginPage(w, \"\")\n}\n\nfunc logoutHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Redirect(w, r, \"/login\", http.StatusFound)\n\t\treturn\n\t}\n\tcookie, err := r.Cookie(\"session\")\n\tif err == nil && cookie.Value != \"\" {\n\t\tsessionsMu.Lock()\n\t\tdelete(sessions, cookie.Value)\n\t\tsessionsMu.Unlock()\n\t}\n\thttp.SetCookie(w, &http.Cookie{Name: \"session\", Value: \"\", Path: \"/\", HttpOnly: true, MaxAge: -1})\n\thttp.Redirect(w, r, \"/login\", http.StatusFound)\n}\n\n// ======================== Token 统计 ========================\n\ntype ModelStats struct {\n\tRequestCount int64 `json:\"request_count\"`\n\tPromptTokens int64 `json:\"prompt_tokens\"`\n\tCompletionTokens int64 `json:\"completion_tokens\"`\n\tTotalTokens int64 `json:\"total_tokens\"`\n}\n\ntype TokenStatsData struct {\n\tTotalRequests int64 `json:\"total_requests\"`\n\tModels map[string]*ModelStats `json:\"models\"`\n}\n\nvar (\n\ttokenStats = &TokenStatsData{Models: map[string]*ModelStats{}}\n\ttokenStatsMu sync.Mutex\n\ttokenStatsPath = \"stats.json\"\n)\n\n// ======================== 数据模型 ========================\n\ntype OpenAIRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []Message `json:\"messages\"`\n\tStream bool `json:\"stream\"`\n\tTemperature *float64 `json:\"temperature,omitempty\"`\n\tMaxTokens *int `json:\"max_tokens,omitempty\"`\n\tTopP *float64 `json:\"top_p,omitempty\"`\n\tThinking any `json:\"thinking,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tExtraBody map[string]any `json:\"extra_body,omitempty\"`\n\tTools []Tool `json:\"tools,omitempty\"`\n\tToolChoice any `json:\"tool_choice,omitempty\"`\n}\n\ntype Message struct {\n\tRole string `json:\"role,omitempty\"`\n\tContent any `json:\"content,omitempty\"`\n\tToolCalls []ToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\tReasoningContent *string `json:\"reasoning_content,omitempty\"`\n}\n\ntype ToolCall struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tFunction FunctionCall `json:\"function\"`\n}\n\ntype FunctionCall struct {\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\ntype Tool struct {\n\tType string `json:\"type\"`\n\tFunction ToolFunction `json:\"function\"`\n}\n\ntype ToolFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters map[string]any `json:\"parameters\"`\n}\n\ntype AppConfig struct {\n\tModelAlias map[string]string `json:\"model_alias\"`\n\tReasoningEffortMap map[string]string `json:\"reasoning_effort_map\"`\n\tForceDisableThinking bool `json:\"force_disable_thinking\"`\n\tSocks5Proxies []Socks5Proxy `json:\"socks5_proxies,omitempty\"`\n\tActiveSocks5 string `json:\"active_socks5,omitempty\"`\n\t// Socks5PaidDirect controls whether keyed/paid upstream calls bypass SOCKS5.\n\t// Omitted or false (default): all traffic uses the active proxy.\n\t// true: paid/keyed traffic goes direct; only public/free uses SOCKS5.\n\tSocks5PaidDirect bool `json:\"socks5_paid_direct,omitempty\"`\n}\n\n// ======================== Claude Messages API 类型 ========================\n\ntype ClaudeRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []ClaudeMessage `json:\"messages\"`\n\tSystem any `json:\"system,omitempty\"`\n\tMaxTokens *int `json:\"max_tokens,omitempty\"`\n\tTemperature *float64 `json:\"temperature,omitempty\"`\n\tTopP *float64 `json:\"top_p,omitempty\"`\n\tTopK *int `json:\"top_k,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\tTools []ClaudeTool `json:\"tools,omitempty\"`\n\tToolChoice any `json:\"tool_choice,omitempty\"`\n\tStopSequences []string `json:\"stop_sequences,omitempty\"`\n\tMetadata any `json:\"metadata,omitempty\"`\n\tThinking any `json:\"thinking,omitempty\"`\n\tOutputConfig any `json:\"output_config,omitempty\"`\n\tContextManagement any `json:\"context_management,omitempty\"`\n}\n\ntype ClaudeMessage struct {\n\tRole string `json:\"role\"`\n\tContent any `json:\"content\"`\n}\n\ntype ClaudeContent struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tThinking string `json:\"thinking,omitempty\"`\n\tSignature string `json:\"signature,omitempty\"`\n\tID string `json:\"id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\tInput any `json:\"input,omitempty\"`\n\tToolUseID string `json:\"tool_use_id,omitempty\"`\n\tContent any `json:\"content,omitempty\"`\n}\n\ntype ClaudeTool struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tInputSchema any `json:\"input_schema\"`\n\tType string `json:\"type,omitempty\"`\n}\n\ntype ClaudeResponse struct {\n\tID string `json:\"id\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tContent []ClaudeContent `json:\"content\"`\n\tModel string `json:\"model\"`\n\tStopReason string `json:\"stop_reason\"`\n\tUsage ClaudeUsage `json:\"usage,omitempty\"`\n}\n\ntype ClaudeUsage map[string]any\n\n// ======================== Responses API 类型 ========================\n\ntype ResponsesAPIRequest struct {\n\tModel string `json:\"model\"`\n\tInput any `json:\"input\"`\n\tMessages []Message `json:\"messages,omitempty\"`\n\tInstructions string `json:\"instructions,omitempty\"`\n\tPreviousResponseID string `json:\"previous_response_id,omitempty\"`\n\tStream bool `json:\"stream,omitempty\"`\n\tTemperature *float64 `json:\"temperature,omitempty\"`\n\tMaxTokens *int `json:\"max_output_tokens,omitempty\"`\n\tTopP *float64 `json:\"top_p,omitempty\"`\n\tFrequencyPenalty *float64 `json:\"frequency_penalty,omitempty\"`\n\tPresencePenalty *float64 `json:\"presence_penalty,omitempty\"`\n\tReasoning ReasonEffort `json:\"reasoning,omitempty\"`\n\tInclude []string `json:\"include,omitempty\"`\n\tStore *bool `json:\"store,omitempty\"`\n\tTools []ResponsesTool `json:\"tools,omitempty\"`\n\tToolChoice any `json:\"tool_choice,omitempty\"`\n\tParallelToolCalls *bool `json:\"parallel_tool_calls,omitempty\"`\n\tStop any `json:\"stop,omitempty\"`\n\tUser string `json:\"user,omitempty\"`\n\tStreamOptions any `json:\"stream_options,omitempty\"`\n\tMetadata any `json:\"metadata,omitempty\"`\n}\n\ntype ResponsesTool struct {\n\tType string `json:\"type\"`\n\tName string `json:\"name,omitempty\"`\n\tDescription string `json:\"description,omitempty\"`\n\tParameters map[string]any `json:\"parameters,omitempty\"`\n\tFunction *ToolFunction `json:\"function,omitempty\"`\n\tServerLabel string `json:\"server_label,omitempty\"`\n\tServerURL string `json:\"server_url,omitempty\"`\n\tConnectorID string `json:\"connector_id,omitempty\"`\n\tAuthorization string `json:\"authorization,omitempty\"`\n\tAllowedTools []string `json:\"allowed_tools,omitempty\"`\n\tRequireApproval any `json:\"require_approval,omitempty\"`\n}\n\ntype ReasonEffort struct {\n\tEffort string `json:\"effort,omitempty\"`\n}\n\ntype StoredResponseState struct {\n\tModel string `json:\"model\"`\n\tInstructions string `json:\"instructions,omitempty\"`\n\tTools []ResponsesTool `json:\"tools,omitempty\"`\n\tToolChoice any `json:\"tool_choice,omitempty\"`\n\tOutput []any `json:\"output,omitempty\"`\n}\n\n// ======================== 配置管理 ========================\n\nfunc loadConfig(path string) AppConfig {\n\tvar cfg AppConfig\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn cfg\n\t}\n\tif err := json.Unmarshal(data, &cfg); err != nil {\n\t\tslog.Warn(\"config parse failed\", \"error\", err)\n\t}\n\treturn cfg\n}\n\nfunc saveConfig(path string, cfg AppConfig) error {\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn os.WriteFile(path, data, 0644)\n}\n\nfunc applyConfig(cfg AppConfig) {\n\tconfigMu.Lock()\n\tdefer configMu.Unlock()\n\tif cfg.ModelAlias != nil {\n\t\tmodelAlias = cfg.ModelAlias\n\t}\n\tif cfg.ReasoningEffortMap != nil {\n\t\treasoningEffortMap = cfg.ReasoningEffortMap\n\t}\n\tforceDisableThinking = cfg.ForceDisableThinking\n\n\tsocks5Mu.Lock()\n\tif cfg.Socks5Proxies != nil {\n\t\tsocks5Proxies = cfg.Socks5Proxies\n\t}\n\tif activeSocks5 != cfg.ActiveSocks5 {\n\t\tactiveSocks5 = cfg.ActiveSocks5\n\t\tsocks5Client = nil\n\t\tsocks5ClientAddr = \"\"\n\t\tatomic.StoreUint32(&socks5RRIndex, 0)\n\t}\n\tsocks5PaidDirect = cfg.Socks5PaidDirect\n\tsocks5Mu.Unlock()\n\n}\n\nfunc resolveModel(model string) string {\n\tm := strings.TrimSpace(model)\n\tconfigMu.RLock()\n\talias, ok := modelAlias[m]\n\tconfigMu.RUnlock()\n\tif ok {\n\t\treturn alias\n\t}\n\t// Clients see free models without the \"-free\" suffix from /v1/models.\n\t// Map the display name back to the upstream free ID when that is the only match.\n\tif m != \"\" && !isFreeModel(m) {\n\t\tfreeID := m + \"-free\"\n\t\tif !modelExistsInCaches(m) && modelExistsInCaches(freeID) {\n\t\t\treturn freeID\n\t\t}\n\t}\n\treturn m\n}\n\nfunc getForceDisableThinking() bool {\n\tconfigMu.RLock()\n\tdefer configMu.RUnlock()\n\treturn forceDisableThinking\n}\n\nfunc getReasoningEffortMap() map[string]string {\n\tconfigMu.RLock()\n\tdefer configMu.RUnlock()\n\tcp := make(map[string]string, len(reasoningEffortMap))\n\tfor k, v := range reasoningEffortMap {\n\t\tcp[k] = v\n\t}\n\treturn cp\n}\n\n// ======================== Token 统计 ========================\n\nfunc loadTokenStats() {\n\tdata, err := os.ReadFile(tokenStatsPath)\n\tif err != nil {\n\t\treturn\n\t}\n\tvar st TokenStatsData\n\tif err := json.Unmarshal(data, &st); err != nil {\n\t\treturn\n\t}\n\ttokenStatsMu.Lock()\n\tif st.Models == nil {\n\t\tst.Models = map[string]*ModelStats{}\n\t}\n\ttokenStats = &st\n\ttokenStatsMu.Unlock()\n}\n\nfunc saveTokenStats() {\n\ttokenStatsMu.Lock()\n\tdata, err := json.MarshalIndent(tokenStats, \"\", \" \")\n\ttokenStatsMu.Unlock()\n\tif err != nil {\n\t\treturn\n\t}\n\tos.WriteFile(tokenStatsPath, data, 0644)\n}\n\nfunc recordTokenUsage(model string, promptTokens, completionTokens, totalTokens int64) {\n\ttokenStatsMu.Lock()\n\ttokenStats.TotalRequests++\n\tms, ok := tokenStats.Models[model]\n\tif !ok {\n\t\tms = &ModelStats{}\n\t\ttokenStats.Models[model] = ms\n\t}\n\tms.RequestCount++\n\tms.PromptTokens += promptTokens\n\tms.CompletionTokens += completionTokens\n\tms.TotalTokens += totalTokens\n\ttokenStatsMu.Unlock()\n\tgo saveTokenStats()\n}\n\n// ======================== Thinking/Reasoning 判断 ========================\n\nfunc isThinkingEnabled(value any) bool {\n\tswitch v := value.(type) {\n\tcase map[string]any:\n\t\tt, _ := v[\"type\"].(string)\n\t\t// Claude Code sends adaptive thinking with --effort / CLAUDE_CODE_EFFORT_LEVEL.\n\t\treturn t == \"enabled\" || t == \"adaptive\"\n\tcase bool:\n\t\treturn v\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// effortFromOutputConfig reads Claude Code's output_config.effort\n// (set by --effort / CLAUDE_CODE_EFFORT_LEVEL).\nfunc effortFromOutputConfig(value any) string {\n\tm, ok := value.(map[string]any)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\teffort, _ := m[\"effort\"].(string)\n\treturn strings.TrimSpace(effort)\n}\n\nfunc isThinkingDisabled(value any) bool {\n\tswitch v := value.(type) {\n\tcase map[string]any:\n\t\tt, _ := v[\"type\"].(string)\n\t\treturn t == \"disabled\"\n\tcase bool:\n\t\treturn !v\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// buildUpstreamThinking preserves budget_tokens / effort fields when present.\nfunc buildUpstreamThinking(value any) map[string]any {\n\tout := map[string]any{\"type\": \"enabled\"}\n\tm, ok := value.(map[string]any)\n\tif !ok {\n\t\treturn out\n\t}\n\tfor _, key := range []string{\"budget_tokens\", \"effort\"} {\n\t\tif v, exists := m[key]; exists && v != nil {\n\t\t\tout[key] = v\n\t\t}\n\t}\n\treturn out\n}\n\n// reasoningEffortFromThinking maps Anthropic-style budget_tokens onto an\n// OpenAI-compatible reasoning_effort when the client did not set one explicitly.\nfunc reasoningEffortFromThinking(value any) string {\n\tm, ok := value.(map[string]any)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tif effort, ok := m[\"effort\"].(string); ok && effort != \"\" {\n\t\treturn effort\n\t}\n\tvar budget float64\n\tswitch v := m[\"budget_tokens\"].(type) {\n\tcase float64:\n\t\tbudget = v\n\tcase int:\n\t\tbudget = float64(v)\n\tcase int64:\n\t\tbudget = float64(v)\n\tcase json.Number:\n\t\tf, err := v.Float64()\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\tbudget = f\n\tdefault:\n\t\treturn \"\"\n\t}\n\tswitch {\n\tcase budget <= 0:\n\t\treturn \"\"\n\tcase budget < 2048:\n\t\treturn \"low\"\n\tcase budget < 8192:\n\t\treturn \"medium\"\n\tcase budget < 16384:\n\t\treturn \"high\"\n\tdefault:\n\t\treturn \"xhigh\"\n\t}\n}\n\nfunc wantsReasoning(req *OpenAIRequest) bool {\n\tif getForceDisableThinking() {\n\t\treturn false\n\t}\n\tif isThinkingDisabled(req.Thinking) {\n\t\treturn false\n\t}\n\tif isThinkingEnabled(req.Thinking) {\n\t\treturn true\n\t}\n\tif req.ExtraBody != nil {\n\t\tif isThinkingDisabled(req.ExtraBody[\"thinking\"]) {\n\t\t\treturn false\n\t\t}\n\t\tif isThinkingEnabled(req.ExtraBody[\"thinking\"]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn true\n}\n\n// ======================== 消息处理 ========================\n// normalizeContent 是 dumb pipe 透传:保留 string 与 []any 两种入参形状\n// (其它非常规类型走 json.Marshal 兜底),不解析或过滤任何 multimodal part。\n// 能力协商由 opencode 客户端 + 上游负责;这里既不\"硬降级\"也不\"补全\"。\nfunc normalizeContent(content any) any {\n\tif content == nil {\n\t\treturn nil\n\t}\n\tif s, ok := content.(string); ok {\n\t\treturn s\n\t}\n\tif arr, ok := content.([]any); ok {\n\t\treturn arr\n\t}\n\tb, err := json.Marshal(content)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn string(b)\n}\n\nfunc fixToolCallGaps(messages []Message) []Message {\n\ttoolResponses := map[string]*Message{}\n\tfor i := range messages {\n\t\tif messages[i].Role == \"tool\" && messages[i].ToolCallID != \"\" {\n\t\t\ttoolResponses[messages[i].ToolCallID] = &messages[i]\n\t\t}\n\t}\n\tfixed := make([]Message, 0, len(messages)+len(messages)/4)\n\temitted := map[string]bool{}\n\tfor _, msg := range messages {\n\t\tif msg.Role == \"tool\" && msg.ToolCallID != \"\" {\n\t\t\tif emitted[msg.ToolCallID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tfixed = append(fixed, msg)\n\t\tif msg.Role == \"assistant\" && len(msg.ToolCalls) > 0 {\n\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\tif resp, found := toolResponses[tc.ID]; found {\n\t\t\t\t\tfixed = append(fixed, *resp)\n\t\t\t\t} else {\n\t\t\t\t\tfixed = append(fixed, Message{Role: \"tool\", ToolCallID: tc.ID, Content: \"Tool call result not available\"})\n\t\t\t\t}\n\t\t\t\temitted[tc.ID] = true\n\t\t\t}\n\t\t}\n\t}\n\treturn fixed\n}\n\nfunc ensureReasoningContent(messages []Message, thinking bool) []Message {\n\tif !thinking {\n\t\treturn messages\n\t}\n\tfor i := range messages {\n\t\tif messages[i].Role == \"assistant\" && messages[i].ReasoningContent == nil {\n\t\t\tempty := \"\"\n\t\t\tmessages[i].ReasoningContent = &empty\n\t\t}\n\t}\n\treturn messages\n}\n\nfunc convertMessagesForUpstream(messages []Message) []map[string]any {\n\tconverted := make([]map[string]any, 0, len(messages))\n\tfor _, msg := range messages {\n\t\tclean := map[string]any{}\n\t\tif msg.Role != \"\" {\n\t\t\tclean[\"role\"] = msg.Role\n\t\t}\n\t\tcontent := normalizeContent(msg.Content)\n\t\treasoningContent := msg.ReasoningContent\n\t\tif content != nil {\n\t\t\tclean[\"content\"] = content\n\t\t}\n\t\tif reasoningContent != nil {\n\t\t\tclean[\"reasoning_content\"] = *reasoningContent\n\t\t}\n\t\tif len(msg.ToolCalls) > 0 {\n\t\t\tclean[\"tool_calls\"] = msg.ToolCalls\n\t\t}\n\t\tif msg.ToolCallID != \"\" {\n\t\t\tclean[\"tool_call_id\"] = msg.ToolCallID\n\t\t}\n\t\tif msg.Name != \"\" {\n\t\t\tclean[\"name\"] = msg.Name\n\t\t}\n\t\tconverted = append(converted, clean)\n\t}\n\treturn converted\n}\n\n// ======================== 完整请求转换(含 thinking/reasoning_effort/ExtraBody) ========================\n\nfunc convertRequest(req *OpenAIRequest) map[string]any {\n\tconverted := map[string]any{\n\t\t\"model\": req.Model,\n\t\t\"messages\": convertMessagesForUpstream(req.Messages),\n\t\t\"stream\": req.Stream,\n\t}\n\tif req.Temperature != nil {\n\t\tconverted[\"temperature\"] = *req.Temperature\n\t}\n\tif req.MaxTokens != nil {\n\t\tconverted[\"max_tokens\"] = *req.MaxTokens\n\t}\n\tif req.TopP != nil {\n\t\tconverted[\"top_p\"] = *req.TopP\n\t}\n\tif len(req.Tools) > 0 {\n\t\tconverted[\"tools\"] = req.Tools\n\t}\n\tif req.ToolChoice != nil {\n\t\tconverted[\"tool_choice\"] = req.ToolChoice\n\t}\n\t// 处理思维模式 — 仅当用户显式指定时才发送,避免 MiniMax 等模型报错\n\tif getForceDisableThinking() || isThinkingDisabled(req.Thinking) {\n\t\tconverted[\"thinking\"] = map[string]string{\"type\": \"disabled\"}\n\t} else if req.Thinking != nil && isThinkingEnabled(req.Thinking) {\n\t\tconverted[\"thinking\"] = buildUpstreamThinking(req.Thinking)\n\t} else if req.ExtraBody != nil {\n\t\tif isThinkingDisabled(req.ExtraBody[\"thinking\"]) {\n\t\t\tconverted[\"thinking\"] = map[string]string{\"type\": \"disabled\"}\n\t\t} else if isThinkingEnabled(req.ExtraBody[\"thinking\"]) {\n\t\t\tconverted[\"thinking\"] = buildUpstreamThinking(req.ExtraBody[\"thinking\"])\n\t\t}\n\t}\n\t// 处理 reasoning_effort(含从 thinking.budget_tokens 推导)\n\teffort := req.ReasoningEffort\n\tif effort == \"\" && !isThinkingDisabled(req.Thinking) {\n\t\teffort = reasoningEffortFromThinking(req.Thinking)\n\t}\n\tif !getForceDisableThinking() && effort != \"\" {\n\t\teffortMap := getReasoningEffortMap()\n\t\tif mapped, ok := effortMap[effort]; ok {\n\t\t\tconverted[\"reasoning_effort\"] = mapped\n\t\t} else {\n\t\t\tconverted[\"reasoning_effort\"] = effort\n\t\t}\n\t}\n\t// 合并 ExtraBody\n\tif req.ExtraBody != nil {\n\t\tfor k, v := range req.ExtraBody {\n\t\t\tif _, exists := converted[k]; !exists {\n\t\t\t\tconverted[k] = v\n\t\t\t}\n\t\t}\n\t}\n\treturn converted\n}\n\nfunc buildUpstreamBody(req *OpenAIRequest) []byte {\n\tconverted := convertRequest(req)\n\tb, err := json.Marshal(converted)\n\tif err != nil {\n\t\tslog.Error(\"marshal upstream body failed\", \"error\", err)\n\t}\n\treturn b\n}\n\n// ======================== Anthropic 格式兼容 ========================\n\nfunc isAnthropicFormat(body []byte) bool {\n\tvar obj map[string]any\n\tif json.Unmarshal(body, &obj) == nil {\n\t\tif typ, _ := obj[\"type\"].(string); typ == \"message\" {\n\t\t\treturn true\n\t\t}\n\t}\n\tlines := bytes.Split(body, []byte(\"\\n\"))\n\tfor _, line := range lines {\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar event map[string]any\n\t\tif err := json.Unmarshal(line, &event); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttyp, _ := event[\"type\"].(string)\n\t\tswitch typ {\n\t\tcase \"message_start\", \"content_block_start\", \"content_block_delta\",\n\t\t\t\"content_block_stop\", \"message_delta\", \"message_stop\", \"ping\":\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\treturn false\n}\n\nfunc parseAnthropicSSE(body []byte) (map[string]any, string, []map[string]any) {\n\tlines := bytes.Split(body, []byte(\"\\n\"))\n\tvar anthropicMsg map[string]any\n\tvar textBuilder, currentToolInputBuilder strings.Builder\n\tvar currentToolUse map[string]any\n\tvar toolUseBlocks []map[string]any\n\tfor _, line := range lines {\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar event map[string]any\n\t\tif err := json.Unmarshal(line, &event); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttyp, _ := event[\"type\"].(string)\n\t\tswitch typ {\n\t\tcase \"message_start\":\n\t\t\tif m, ok := event[\"message\"].(map[string]any); ok {\n\t\t\t\tanthropicMsg = m\n\t\t\t}\n\t\tcase \"content_block_start\":\n\t\t\tif cb, ok := event[\"content_block\"].(map[string]any); ok {\n\t\t\t\tif cbType, _ := cb[\"type\"].(string); cbType == \"tool_use\" {\n\t\t\t\t\tcurrentToolUse = cb\n\t\t\t\t\tcurrentToolInputBuilder.Reset()\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"content_block_delta\":\n\t\t\tif delta, ok := event[\"delta\"].(map[string]any); ok {\n\t\t\t\tif t, ok := delta[\"text\"].(string); ok {\n\t\t\t\t\ttextBuilder.WriteString(t)\n\t\t\t\t}\n\t\t\t\tif dt, _ := delta[\"type\"].(string); dt == \"input_json_delta\" {\n\t\t\t\t\tif partial, ok := delta[\"partial_json\"].(string); ok {\n\t\t\t\t\t\tcurrentToolInputBuilder.WriteString(partial)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"content_block_stop\":\n\t\t\tif currentToolUse != nil {\n\t\t\t\tinputStr := currentToolInputBuilder.String()\n\t\t\t\tvar input any = inputStr\n\t\t\t\tvar parsed any\n\t\t\t\tif json.Unmarshal([]byte(inputStr), &parsed) == nil {\n\t\t\t\t\tinput = parsed\n\t\t\t\t}\n\t\t\t\tcurrentToolUse[\"input\"] = input\n\t\t\t\ttoolUseBlocks = append(toolUseBlocks, currentToolUse)\n\t\t\t\tcurrentToolUse = nil\n\t\t\t}\n\t\tcase \"message_delta\":\n\t\t\tif delta, ok := event[\"delta\"].(map[string]any); ok {\n\t\t\t\tif anthropicMsg == nil {\n\t\t\t\t\tanthropicMsg = map[string]any{}\n\t\t\t\t}\n\t\t\t\tif stop, ok := delta[\"stop_reason\"].(string); ok {\n\t\t\t\t\tanthropicMsg[\"stop_reason\"] = stop\n\t\t\t\t}\n\t\t\t\tif usage, ok := delta[\"usage\"].(map[string]any); ok {\n\t\t\t\t\tanthropicMsg[\"usage\"] = usage\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"message_stop\":\n\t\tcase \"error\":\n\t\t\treturn nil, \"\", nil\n\t\t}\n\t}\n\treturn anthropicMsg, textBuilder.String(), toolUseBlocks\n}\n\nfunc buildOpenAIResponse(anthropicMsg map[string]any, text string, toolUseBlocks []map[string]any, modelID string) []byte {\n\tif anthropicMsg == nil {\n\t\treturn nil\n\t}\n\tnow := time.Now().Unix()\n\trole, _ := anthropicMsg[\"role\"].(string)\n\tif role == \"\" {\n\t\trole = \"assistant\"\n\t}\n\tfinishReason, _ := anthropicMsg[\"stop_reason\"].(string)\n\tfinishReason = normalizeFinishReason(finishReason)\n\tchoice := map[string]any{\n\t\t\"index\": 0,\n\t\t\"message\": map[string]any{\"role\": role, \"content\": text},\n\t\t\"finish_reason\": finishReason,\n\t}\n\tif len(toolUseBlocks) > 0 {\n\t\tvar toolCalls []map[string]any\n\t\tfor _, tb := range toolUseBlocks {\n\t\t\ttoolInput := tb[\"input\"]\n\t\t\targsJSON, _ := json.Marshal(toolInput)\n\t\t\ttoolCalls = append(toolCalls, map[string]any{\n\t\t\t\t\"id\": tb[\"id\"],\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": map[string]any{\n\t\t\t\t\t\"name\": tb[\"name\"],\n\t\t\t\t\t\"arguments\": string(argsJSON),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t\tchoice[\"message\"].(map[string]any)[\"tool_calls\"] = toolCalls\n\t\tif text == \"\" {\n\t\t\tchoice[\"message\"].(map[string]any)[\"content\"] = nil\n\t\t}\n\t}\n\tresp := map[string]any{\n\t\t\"id\": anthropicMsg[\"id\"],\n\t\t\"object\": \"chat.completion\",\n\t\t\"created\": now,\n\t\t\"model\": modelID,\n\t\t\"choices\": []map[string]any{choice},\n\t}\n\tif usage, ok := anthropicMsg[\"usage\"].(map[string]any); ok {\n\t\tresp[\"usage\"] = anthropicUsageToChat(usage)\n\t}\n\tresult, _ := json.Marshal(resp)\n\treturn result\n}\n\nfunc convertAnthropicMessageToOpenAI(msg map[string]any, modelID string) []byte {\n\tif msg[\"model\"] == nil {\n\t\tmsg[\"model\"] = modelID\n\t}\n\tvar textBuilder strings.Builder\n\tvar toolUses []map[string]any\n\tif content, ok := msg[\"content\"].([]any); ok {\n\t\tfor _, c := range content {\n\t\t\tif block, ok := c.(map[string]any); ok {\n\t\t\t\tswitch block[\"type\"] {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tif t, ok := block[\"text\"].(string); ok {\n\t\t\t\t\t\ttextBuilder.WriteString(t)\n\t\t\t\t\t}\n\t\t\t\tcase \"tool_use\":\n\t\t\t\t\ttoolUses = append(toolUses, block)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn buildOpenAIResponse(msg, textBuilder.String(), toolUses, modelID)\n}\n\nfunc convertAnthropicToOpenAI(body []byte, modelID string) []byte {\n\tvar singleMsg map[string]any\n\tif json.Unmarshal(body, &singleMsg) == nil {\n\t\tif typ, _ := singleMsg[\"type\"].(string); typ == \"message\" {\n\t\t\treturn convertAnthropicMessageToOpenAI(singleMsg, modelID)\n\t\t}\n\t}\n\tmsg, text, toolUses := parseAnthropicSSE(body)\n\tif msg == nil {\n\t\treturn body\n\t}\n\tif msg[\"model\"] == nil {\n\t\tmsg[\"model\"] = modelID\n\t}\n\treturn buildOpenAIResponse(msg, text, toolUses, modelID)\n}\n\n// ======================== 响应清理 ========================\n\nfunc cleanNulls(m map[string]any) {\n\tfor k, v := range m {\n\t\tif v == nil {\n\t\t\tdelete(m, k)\n\t\t\tcontinue\n\t\t}\n\t\tif s, ok := v.(string); ok && s == \"\" {\n\t\t\tdelete(m, k)\n\t\t}\n\t}\n}\n\n// promoteMisplacedReasoning moves reasoning_content into content when upstream\n// put the visible answer in reasoning_content (opencode-go #37635). Only runs\n// when content is empty and the chunk has no tool_calls, so genuine CoT that\n// precedes tool calls is left alone when keepReasoning is true.\nfunc promoteMisplacedReasoning(fields map[string]any, keepReasoning bool) bool {\n\trc, _ := fields[\"reasoning_content\"].(string)\n\tif rc == \"\" {\n\t\treturn false\n\t}\n\tif raw, ok := fields[\"tool_calls\"]; ok && raw != nil {\n\t\tif arr, ok := raw.([]any); ok && len(arr) > 0 {\n\t\t\treturn false\n\t\t}\n\t}\n\tcontent, _ := fields[\"content\"].(string)\n\tif content != \"\" {\n\t\treturn false\n\t}\n\tif keepReasoning {\n\t\t// Preserve CoT for thinking blocks / clients that read reasoning_content.\n\t\treturn false\n\t}\n\tfields[\"content\"] = rc\n\tdelete(fields, \"reasoning_content\")\n\treturn true\n}\n\nfunc cleanStreamDelta(delta map[string]any, keepReasoning bool) {\n\t_ = promoteMisplacedReasoning(delta, keepReasoning)\n\tif v, ok := delta[\"content\"]; ok && v == nil {\n\t\tdelete(delta, \"content\")\n\t}\n\tif s, ok := delta[\"content\"].(string); ok && s == \"\" {\n\t\tdelete(delta, \"content\")\n\t}\n\tif !keepReasoning {\n\t\tdelete(delta, \"reasoning_content\")\n\t} else {\n\t\tif v, ok := delta[\"reasoning_content\"]; ok && v == nil {\n\t\t\tdelete(delta, \"reasoning_content\")\n\t\t}\n\t\tif s, ok := delta[\"reasoning_content\"].(string); ok && s == \"\" {\n\t\t\tdelete(delta, \"reasoning_content\")\n\t\t}\n\t}\n\tif s, ok := delta[\"role\"].(string); ok && s == \"\" {\n\t\tdelete(delta, \"role\")\n\t}\n}\n\n// convertStreamChunkWithUsage 转换流式 chunk 并同时提取 usage,避免二次解析\nfunc convertStreamChunkWithUsage(line string, keepReasoning bool) (string, map[string]any) {\n\ttrimmed := strings.TrimSpace(line)\n\tif trimmed == \"data: [DONE]\" || trimmed == \"[DONE]\" {\n\t\treturn line, nil\n\t}\n\tif !strings.HasPrefix(line, \"data: \") {\n\t\treturn line, nil\n\t}\n\tdata := line[6:]\n\tvar raw map[string]any\n\tif err := json.Unmarshal([]byte(data), &raw); err != nil {\n\t\treturn line, nil\n\t}\n\n\t// 提取 usage\n\tvar usage map[string]any\n\tif u, ok := raw[\"usage\"].(map[string]any); ok {\n\t\tusage = u\n\t}\n\n\tchoices, ok := raw[\"choices\"].([]any)\n\tif !ok || len(choices) == 0 {\n\t\t// Chat Completions deliberately uses an empty choices array for the\n\t\t// terminal usage chunk. It is part of the client-visible stream.\n\t\tdelete(raw, \"cost\")\n\t\tconverted, err := json.Marshal(raw)\n\t\tif err != nil {\n\t\t\treturn line, usage\n\t\t}\n\t\treturn \"data: \" + string(converted), usage\n\t}\n\tfor i, c := range choices {\n\t\tchoice, ok := c.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif delta, ok := choice[\"delta\"].(map[string]any); ok {\n\t\t\tcleanStreamDelta(delta, keepReasoning)\n\t\t\tchoice[\"delta\"] = delta\n\t\t}\n\t\tif msg, ok := choice[\"message\"].(map[string]any); ok {\n\t\t\tcleanNulls(msg)\n\t\t\tpromoteMisplacedReasoning(msg, keepReasoning)\n\t\t\tif !keepReasoning {\n\t\t\t\tdelete(msg, \"reasoning_content\")\n\t\t\t}\n\t\t\tchoice[\"message\"] = msg\n\t\t}\n\t\tif v, ok := choice[\"logprobs\"]; ok && v == nil {\n\t\t\tdelete(choice, \"logprobs\")\n\t\t}\n\t\tif v, ok := choice[\"finish_reason\"]; ok && v == nil {\n\t\t\tdelete(choice, \"finish_reason\")\n\t\t}\n\t\tif s, ok := choice[\"finish_reason\"].(string); ok && s == \"\" {\n\t\t\tdelete(choice, \"finish_reason\")\n\t\t}\n\t\tchoices[i] = choice\n\t}\n\traw[\"choices\"] = choices\n\tif v, ok := raw[\"usage\"]; ok && v == nil {\n\t\tdelete(raw, \"usage\")\n\t}\n\tdelete(raw, \"cost\")\n\tconverted, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn line, usage\n\t}\n\treturn \"data: \" + string(converted), usage\n}\n\nfunc convertResponse(data []byte, keepReasoning bool) ([]byte, error) {\n\tvar raw map[string]any\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\tslog.Warn(\"convertResponse unmarshal failed\", \"error\", err)\n\t\treturn data, nil\n\t}\n\tif choices, ok := raw[\"choices\"].([]any); ok {\n\t\tfor i, c := range choices {\n\t\t\tif choice, ok := c.(map[string]any); ok {\n\t\t\t\tif msg, ok := choice[\"message\"].(map[string]any); ok {\n\t\t\t\t\tcleanNulls(msg)\n\t\t\t\t\tpromoteMisplacedReasoning(msg, keepReasoning)\n\t\t\t\t\tif !keepReasoning {\n\t\t\t\t\t\tdelete(msg, \"reasoning_content\")\n\t\t\t\t\t}\n\t\t\t\t\tchoice[\"message\"] = msg\n\t\t\t\t}\n\t\t\t\tif v, ok := choice[\"logprobs\"]; ok && v == nil {\n\t\t\t\t\tdelete(choice, \"logprobs\")\n\t\t\t\t}\n\t\t\t\tchoices[i] = choice\n\t\t\t}\n\t\t}\n\t\traw[\"choices\"] = choices\n\t}\n\tdelete(raw, \"cost\")\n\treturn json.Marshal(raw)\n}\n\n// ======================== 认证层级 ========================\n\ntype TierType int\n\nconst (\n\tTierFree TierType = iota\n\tTierPaid\n)\n\ntype AuthRouteMode int\n\nconst (\n\tAuthRoutePublic AuthRouteMode = iota\n\tAuthRouteAuto\n\tAuthRouteZen\n\tAuthRouteGo\n)\n\ntype UpstreamAuth struct {\n\tToken string\n\tMode AuthRouteMode\n\tSource string // authorization | x-api-key | none\n}\n\nfunc extractUpstreamAuth(r *http.Request) UpstreamAuth {\n\ttoken := \"\"\n\tsource := \"none\"\n\tauth := r.Header.Get(\"Authorization\")\n\tif strings.HasPrefix(auth, \"Bearer \") {\n\t\ttoken = strings.TrimSpace(strings.TrimPrefix(auth, \"Bearer \"))\n\t\tsource = \"authorization\"\n\t}\n\tif token == \"\" {\n\t\tif key := strings.TrimSpace(r.Header.Get(\"x-api-key\")); key != \"\" {\n\t\t\ttoken = key\n\t\t\tsource = \"x-api-key\"\n\t\t}\n\t}\n\tif token == \"\" || token == \"public\" {\n\t\tsrc := source\n\t\tif token == \"\" {\n\t\t\tsrc = \"none\"\n\t\t}\n\t\treturn UpstreamAuth{Mode: AuthRoutePublic, Source: src}\n\t}\n\t// go:/zen: 前缀路由:去掉前缀后剩余部分仍需是有效 key(sk- 开头)\n\tif rest, ok := strings.CutPrefix(token, \"go:\"); ok && isValidOpenCodeKey(rest) {\n\t\treturn UpstreamAuth{Token: rest, Mode: AuthRouteGo, Source: source}\n\t}\n\tif rest, ok := strings.CutPrefix(token, \"zen:\"); ok && isValidOpenCodeKey(rest) {\n\t\treturn UpstreamAuth{Token: rest, Mode: AuthRouteZen, Source: source}\n\t}\n\t// 只有 sk- 开头的才是有效 key,其余(no-key-required 等占位符)一律走 public\n\tif isValidOpenCodeKey(token) {\n\t\treturn UpstreamAuth{Token: token, Mode: AuthRouteAuto, Source: source}\n\t}\n\treturn UpstreamAuth{Mode: AuthRoutePublic, Source: source}\n}\n\n// 只认 sk- 开头的 opencode key;Anthropic sk-ant-* 不能转发上游。\nfunc isValidOpenCodeKey(token string) bool {\n\tif strings.HasPrefix(token, \"sk-ant-\") {\n\t\treturn false\n\t}\n\treturn strings.HasPrefix(token, \"sk-\") && len(token) > 15\n}\n\nfunc (auth UpstreamAuth) tier() TierType {\n\tif auth.Mode == AuthRoutePublic {\n\t\treturn TierFree\n\t}\n\treturn TierPaid\n}\n\nfunc (auth UpstreamAuth) authorizationHeader() string {\n\tif auth.Mode == AuthRoutePublic {\n\t\treturn \"Bearer public\"\n\t}\n\treturn \"Bearer \" + auth.Token\n}\n\nfunc (auth UpstreamAuth) shouldUseGoCatalog() bool {\n\treturn auth.Mode == AuthRouteGo\n}\n\nfunc (auth UpstreamAuth) shouldUseGoEndpoint(modelID string) bool {\n\tswitch auth.Mode {\n\tcase AuthRouteGo:\n\t\treturn isModelInGoCatalog(modelID)\n\tcase AuthRouteAuto:\n\t\treturn isGoCatalogOnlyModel(modelID)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// isFreeModel 判断模型是否属于免费模型(以 -free 结尾)\nfunc isFreeModel(modelID string) bool {\n\treturn strings.HasSuffix(modelID, \"-free\")\n}\n\n// publicFacingModelID strips the upstream \"-free\" suffix for client-visible catalogs.\nfunc publicFacingModelID(modelID string) string {\n\tif isFreeModel(modelID) {\n\t\treturn strings.TrimSuffix(modelID, \"-free\")\n\t}\n\treturn modelID\n}\n\nfunc modelExistsInCaches(modelID string) bool {\n\tmodelMu.RLock()\n\tdefer modelMu.RUnlock()\n\treturn containsModelWithID(modelsCache, modelID) || containsModelWithID(goModelsCache, modelID)\n}\n\nfunc buildOCRequest(modelID string, bodyMap map[string]any, auth UpstreamAuth) (*http.Request, error) {\n\treturn buildOCRequestWithEndpoint(modelID, bodyMap, auth, auth.shouldUseGoEndpoint(modelID))\n}\n\nfunc buildOCRequestWithEndpoint(modelID string, bodyMap map[string]any, auth UpstreamAuth, useGoEndpoint bool) (*http.Request, error) {\n\tbodyMap[\"model\"] = modelID\n\ttryBody, err := json.Marshal(bodyMap)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar upstreamURL string\n\tif useGoEndpoint {\n\t\tupstreamURL = \"https://opencode.ai/zen/go/v1/chat/completions\"\n\t} else {\n\t\tupstreamURL = \"https://opencode.ai/zen/v1/chat/completions\"\n\t}\n\treq, err := http.NewRequest(\"POST\", upstreamURL, bytes.NewReader(tryBody))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", auth.authorizationHeader())\n\treq.Header.Set(\"User-Agent\", fmt.Sprintf(\"opencode/%s\", ocClientVer))\n\treq.Header.Set(\"x-opencode-client\", \"cli\")\n\treq.Header.Set(\"x-opencode-project\", ocProjectID)\n\treq.Header.Set(\"x-opencode-session\", ocSessionID)\n\treq.Header.Set(\"x-opencode-request\", \"req_\"+randomString(24))\n\treq.Header.Set(\"Accept\", \"application/json\")\n\treturn req, nil\n}\n\nfunc shouldRetryUpstreamStatus(status int) bool {\n\t// 仅重试可恢复的临时性错误(始终同模型重试,不换模型)\n\tswitch status {\n\tcase http.StatusUnauthorized, // 401 认证过期或 token 未同步\n\t\thttp.StatusTooManyRequests, // 429 限流\n\t\thttp.StatusBadGateway, // 502\n\t\thttp.StatusServiceUnavailable, // 503\n\t\thttp.StatusGatewayTimeout: // 504\n\t\treturn true\n\t}\n\t// 其他 5xx 也重试,但 4xx 中只有 401 和 429 重试\n\treturn status >= 500 && status < 600\n}\n\nconst (\n\tmaxUpstreamRetries = 3\n\tmax401Retries = 3\n)\n\nfunc maxAttemptsForUpstreamStatus(status int) int {\n\tif status == http.StatusUnauthorized {\n\t\treturn max401Retries\n\t}\n\treturn maxUpstreamRetries\n}\n\nfunc callOpenCodeAPI(ctx context.Context, upstreamBody []byte, modelID string, auth UpstreamAuth) ([]byte, int, http.Header, error) {\n\tinitOCSession()\n\n\tvar bodyMap map[string]any\n\tif err := json.Unmarshal(upstreamBody, &bodyMap); err != nil {\n\t\treturn nil, 500, nil, fmt.Errorf(\"invalid request body\")\n\t}\n\tuseGoEndpoint := auth.shouldUseGoEndpoint(modelID)\n\tsurface := \"zen\"\n\tif useGoEndpoint {\n\t\tsurface = \"go\"\n\t}\n\tlog := reqLogger(ctx)\n\n\tvar lastErr error\n\tvar retryCount int\n\tvar lastBody []byte\n\tvar lastStatus int\n\tvar lastHeader http.Header\n\tmaxAttempts := maxUpstreamRetries\n\tif max401Retries > maxAttempts {\n\t\tmaxAttempts = max401Retries\n\t}\n\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tup, err := buildOCRequestWithEndpoint(modelID, bodyMap, auth, useGoEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, 500, nil, err\n\t\t}\n\t\tclient := getHTTPClientForTier(auth.tier())\n\t\tattemptStart := time.Now()\n\t\tresp, err := client.Do(up)\n\t\tdurationMs := time.Since(attemptStart).Milliseconds()\n\t\tif err != nil {\n\t\t\tlastErr = err\n\t\t\tlastStatus = 0\n\t\t\tretryReason := \"transport_error\"\n\t\t\tcanRetry := attempt+1 < maxUpstreamRetries\n\t\t\tif !canRetry {\n\t\t\t\tretryReason = \"\"\n\t\t\t}\n\t\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\t\"try_model\", modelID,\n\t\t\t\t\"surface\", surface,\n\t\t\t\t\"status\", 0,\n\t\t\t\t\"duration_ms\", durationMs,\n\t\t\t\t\"attempt_index\", attempt,\n\t\t\t\t\"retry_reason\", retryReason,\n\t\t\t\t\"error\", err.Error(),\n\t\t\t)\n\t\t\tif canRetry {\n\t\t\t\tclient.CloseIdleConnections()\n\t\t\t\tretryCount++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif resp.StatusCode >= 200 && resp.StatusCode < 300 {\n\t\t\tb, readErr := io.ReadAll(resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif readErr != nil {\n\t\t\t\treturn nil, 0, nil, readErr\n\t\t\t}\n\t\t\tif isAnthropicFormat(b) {\n\t\t\t\tb = convertAnthropicToOpenAI(b, modelID)\n\t\t\t}\n\t\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\t\"try_model\", modelID,\n\t\t\t\t\"surface\", surface,\n\t\t\t\t\"status\", resp.StatusCode,\n\t\t\t\t\"duration_ms\", durationMs,\n\t\t\t\t\"attempt_index\", attempt,\n\t\t\t)\n\t\t\tlog.Info(\"upstream_result\",\n\t\t\t\t\"models_tried\", []string{modelID},\n\t\t\t\t\"retries\", retryCount,\n\t\t\t\t\"final_status\", resp.StatusCode,\n\t\t\t\t\"fallback_used\", false,\n\t\t\t)\n\t\t\treturn b, resp.StatusCode, resp.Header, nil\n\t\t}\n\t\terrBody, _ := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tlogUpstreamError(ctx, modelID, resp.StatusCode, errBody)\n\t\tnonRetryable := isNonRetryableUpstreamError(resp.StatusCode, errBody)\n\t\tcanRetry := !nonRetryable && shouldRetryUpstreamStatus(resp.StatusCode) && attempt+1 < maxAttemptsForUpstreamStatus(resp.StatusCode)\n\t\tretryReason := \"\"\n\t\tif canRetry {\n\t\t\tretryReason = fmt.Sprintf(\"status_%d\", resp.StatusCode)\n\t\t}\n\t\tif nonRetryable {\n\t\t\tretryReason = \"non_retryable_upstream\"\n\t\t}\n\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\"try_model\", modelID,\n\t\t\t\"surface\", surface,\n\t\t\t\"status\", resp.StatusCode,\n\t\t\t\"duration_ms\", durationMs,\n\t\t\t\"attempt_index\", attempt,\n\t\t\t\"retry_reason\", retryReason,\n\t\t)\n\t\tlastBody = errBody\n\t\tlastStatus = resp.StatusCode\n\t\tlastHeader = resp.Header\n\t\tlastErr = fmt.Errorf(\"upstream error\")\n\t\tif !canRetry {\n\t\t\tbreak\n\t\t}\n\t\tclient.CloseIdleConnections()\n\t\tretryCount++\n\t}\n\tlog.Info(\"upstream_result\",\n\t\t\"models_tried\", []string{modelID},\n\t\t\"retries\", retryCount,\n\t\t\"final_status\", lastStatus,\n\t\t\"fallback_used\", false,\n\t)\n\treturn lastBody, lastStatus, lastHeader, lastErr\n}\n\nfunc callOpenCodeAPIStream(ctx context.Context, upstreamBody []byte, modelID string, auth UpstreamAuth) (io.ReadCloser, int, http.Header, error) {\n\tinitOCSession()\n\n\tvar bodyMap map[string]any\n\tif err := json.Unmarshal(upstreamBody, &bodyMap); err != nil {\n\t\treturn nil, 500, nil, fmt.Errorf(\"invalid request body\")\n\t}\n\tuseGoEndpoint := auth.shouldUseGoEndpoint(modelID)\n\tsurface := \"zen\"\n\tif useGoEndpoint {\n\t\tsurface = \"go\"\n\t}\n\tlog := reqLogger(ctx)\n\n\tvar lastBody []byte\n\tvar lastStatus int\n\tvar lastHeader http.Header\n\tvar retryCount int\n\tmaxAttempts := maxUpstreamRetries\n\tif max401Retries > maxAttempts {\n\t\tmaxAttempts = max401Retries\n\t}\n\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tup, err := buildOCRequestWithEndpoint(modelID, bodyMap, auth, useGoEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, 500, nil, err\n\t\t}\n\t\tclient := getHTTPClientForTier(auth.tier())\n\t\tattemptStart := time.Now()\n\t\tresp, err := client.Do(up)\n\t\tdurationMs := time.Since(attemptStart).Milliseconds()\n\t\tif err != nil {\n\t\t\tretryReason := \"transport_error\"\n\t\t\tcanRetry := attempt+1 < maxUpstreamRetries\n\t\t\tif !canRetry {\n\t\t\t\tretryReason = \"\"\n\t\t\t}\n\t\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\t\"try_model\", modelID,\n\t\t\t\t\"surface\", surface,\n\t\t\t\t\"status\", 0,\n\t\t\t\t\"duration_ms\", durationMs,\n\t\t\t\t\"attempt_index\", attempt,\n\t\t\t\t\"retry_reason\", retryReason,\n\t\t\t\t\"error\", err.Error(),\n\t\t\t)\n\t\t\tif canRetry {\n\t\t\t\tclient.CloseIdleConnections()\n\t\t\t\tretryCount++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif resp.StatusCode >= 200 && resp.StatusCode < 300 {\n\t\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\t\"try_model\", modelID,\n\t\t\t\t\"surface\", surface,\n\t\t\t\t\"status\", resp.StatusCode,\n\t\t\t\t\"duration_ms\", durationMs,\n\t\t\t\t\"attempt_index\", attempt,\n\t\t\t)\n\t\t\tlog.Info(\"upstream_result\",\n\t\t\t\t\"models_tried\", []string{modelID},\n\t\t\t\t\"retries\", retryCount,\n\t\t\t\t\"final_status\", resp.StatusCode,\n\t\t\t\t\"fallback_used\", false,\n\t\t\t)\n\t\t\treturn resp.Body, resp.StatusCode, resp.Header, nil\n\t\t}\n\t\terrBody, _ := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tlogUpstreamError(ctx, modelID, resp.StatusCode, errBody)\n\t\tnonRetryable := isNonRetryableUpstreamError(resp.StatusCode, errBody)\n\t\tcanRetry := !nonRetryable && shouldRetryUpstreamStatus(resp.StatusCode) && attempt+1 < maxAttemptsForUpstreamStatus(resp.StatusCode)\n\t\tretryReason := \"\"\n\t\tif canRetry {\n\t\t\tretryReason = fmt.Sprintf(\"status_%d\", resp.StatusCode)\n\t\t}\n\t\tif nonRetryable {\n\t\t\tretryReason = \"non_retryable_upstream\"\n\t\t}\n\t\tlog.Info(\"upstream_attempt\",\n\t\t\t\"try_model\", modelID,\n\t\t\t\"surface\", surface,\n\t\t\t\"status\", resp.StatusCode,\n\t\t\t\"duration_ms\", durationMs,\n\t\t\t\"attempt_index\", attempt,\n\t\t\t\"retry_reason\", retryReason,\n\t\t)\n\t\tlastBody = errBody\n\t\tlastStatus = resp.StatusCode\n\t\tlastHeader = resp.Header\n\t\tif !canRetry {\n\t\t\tbreak\n\t\t}\n\t\tclient.CloseIdleConnections()\n\t\tretryCount++\n\t}\n\tlog.Info(\"upstream_result\",\n\t\t\"models_tried\", []string{modelID},\n\t\t\"retries\", retryCount,\n\t\t\"final_status\", lastStatus,\n\t\t\"fallback_used\", false,\n\t)\n\tif lastStatus != 0 {\n\t\treturn io.NopCloser(bytes.NewReader(lastBody)), lastStatus, lastHeader, nil\n\t}\n\treturn nil, 500, nil, fmt.Errorf(\"upstream request failed\")\n}\n\n// ======================== 安全响应头过滤 ========================\n\nvar safeResponseHeaders = map[string]bool{\n\t\"Content-Type\": true,\n\t\"X-RateLimit-Limit\": true,\n\t\"X-RateLimit-Remaining\": true,\n\t\"X-RateLimit-Reset\": true,\n}\n\nfunc filterResponseHeaders(h http.Header) http.Header {\n\tfiltered := make(http.Header)\n\tfor k, v := range h {\n\t\tif safeResponseHeaders[k] {\n\t\t\tfiltered[k] = v\n\t\t}\n\t}\n\treturn filtered\n}\n\n// ======================== Chat Completions Handler ========================\n\nfunc chatCompletionsHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\tauth := extractUpstreamAuth(r)\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcnt := requestCount.Add(1)\n\tmaybeLogBodySummary(r.Context(), \"chat completion request body\", body)\n\t_ = cnt\n\n\tvar req OpenAIRequest\n\tif err := json.Unmarshal(body, &req); err != nil {\n\t\thttp.Error(w, \"Invalid JSON\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tmodelIn := req.Model\n\treq.Model = resolveModel(req.Model)\n\tif req.Model == \"\" {\n\t\tmodelIDs := getModelIDs()\n\t\tif len(modelIDs) > 0 {\n\t\t\treq.Model = modelIDs[0]\n\t\t} else {\n\t\t\treq.Model = \"deepseek-v4-flash-free\"\n\t\t}\n\t}\n\n\t// 多模态路由:检测到图片时转发到配置的上游\n\n\treq.Messages = fixToolCallGaps(req.Messages)\n\tkeepReasoning := wantsReasoning(&req)\n\treq.Messages = ensureReasoningContent(req.Messages, keepReasoning)\n\tif req.Stream {\n\t\tif req.ExtraBody == nil {\n\t\t\treq.ExtraBody = map[string]any{}\n\t\t}\n\t\treq.ExtraBody[\"stream_options\"] = map[string]any{\"include_usage\": true}\n\t}\n\teffortIn := req.ReasoningEffort\n\tif effortIn == \"\" && !isThinkingDisabled(req.Thinking) {\n\t\teffortIn = reasoningEffortFromThinking(req.Thinking)\n\t}\n\tupstreamSurface := \"zen\"\n\tif auth.shouldUseGoEndpoint(req.Model) {\n\t\tupstreamSurface = \"go\"\n\t}\n\tlogRequestPlan(r.Context(), map[string]any{\n\t\t\"protocol\": \"chat\",\n\t\t\"model_in\": modelIn,\n\t\t\"model_resolved\": req.Model,\n\t\t\"auth_mode\": authModeString(auth.Mode),\n\t\t\"auth_source\": auth.Source,\n\t\t\"has_key\": auth.Token != \"\",\n\t\t\"upstream_surface\": upstreamSurface,\n\t\t\"stream\": req.Stream,\n\t\t\"keep_reasoning\": keepReasoning,\n\t\t\"thinking\": thinkingState(req.Thinking),\n\t\t\"reasoning_effort_in\": effortIn,\n\t\t\"reasoning_effort_out\": mappedReasoningEffort(effortIn),\n\t\t\"tools_count\": len(req.Tools),\n\t\t\"messages_count\": len(req.Messages),\n\t})\n\tupstreamBody := buildUpstreamBody(&req)\n\n\tif req.Stream {\n\t\tupResp, status, _, err := callOpenCodeAPIStream(r.Context(), upstreamBody, req.Model, auth)\n\t\tif err != nil || status < 200 || status >= 300 {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(status)\n\t\t\tif upResp != nil {\n\t\t\t\terrBody, _ := io.ReadAll(upResp)\n\t\t\t\tif len(errBody) > 0 {\n\t\t\t\t\tw.Write(errBody)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\"error\": map[string]any{\"message\": \"upstream error\", \"type\": \"upstream_error\"}})\n\t\t\treturn\n\t\t}\n\t\tdefer upResp.Close()\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t\tw.Header().Set(\"Connection\", \"keep-alive\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\treader := bufio.NewReader(upResp)\n\t\tstats := &streamResultStats{start: time.Now()}\n\t\tdoneSeen := false\n\t\tfor {\n\t\t\tline, err := reader.ReadString('\\n')\n\t\t\tif err != nil {\n\t\t\t\tif err == io.EOF {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\treqLogger(r.Context()).Error(\"stream read error\", \"error\", err)\n\t\t\t\t// 发送错误事件通知客户端\n\t\t\t\tw.Write([]byte(\"data: {\\\"error\\\":\\\"stream read error\\\"}\\n\\n\"))\n\t\t\t\tif f, ok := w.(http.Flusher); ok {\n\t\t\t\t\tf.Flush()\n\t\t\t\t}\n\t\t\t\tstats.log(r.Context(), \"chat\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif doneSeen {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttrimmed := strings.TrimSpace(line)\n\t\t\tif trimmed == \"data: [DONE]\" {\n\t\t\t\tdoneSeen = true\n\t\t\t\tstats.doneSeen = true\n\t\t\t\tw.Write([]byte(\"data: [DONE]\\n\\n\"))\n\t\t\t\tif f, ok := w.(http.Flusher); ok {\n\t\t\t\t\tf.Flush()\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif strings.HasPrefix(line, \"data: \") {\n\t\t\t\tvar raw map[string]any\n\t\t\t\tif json.Unmarshal([]byte(line[6:]), &raw) == nil {\n\t\t\t\t\tif choices, ok := raw[\"choices\"].([]any); ok && len(choices) > 0 {\n\t\t\t\t\t\tif choice, ok := choices[0].(map[string]any); ok {\n\t\t\t\t\t\t\tif delta, ok := choice[\"delta\"].(map[string]any); ok {\n\t\t\t\t\t\t\t\tstats.observeDelta(delta, keepReasoning)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif fr, ok := choice[\"finish_reason\"].(string); ok && fr != \"\" {\n\t\t\t\t\t\t\t\tstats.finishReason = fr\n\t\t\t\t\t\t\t\tstats.sawFinish = true\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tout, usage := convertStreamChunkWithUsage(line, keepReasoning)\n\t\t\tif out == \"\" {\n\t\t\t\t// 空choices chunk,但可能有 usage\n\t\t\t\tif usage != nil {\n\t\t\t\t\tpt, _ := usage[\"prompt_tokens\"].(float64)\n\t\t\t\t\tct, _ := usage[\"completion_tokens\"].(float64)\n\t\t\t\t\ttt, _ := usage[\"total_tokens\"].(float64)\n\t\t\t\t\tif tt > 0 {\n\t\t\t\t\t\trecordTokenUsage(req.Model, int64(pt), int64(ct), int64(tt))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 提取 usage(已在 convertStreamChunkWithUsage 中解析)\n\t\t\tif usage != nil && !doneSeen {\n\t\t\t\tpt, _ := usage[\"prompt_tokens\"].(float64)\n\t\t\t\tct, _ := usage[\"completion_tokens\"].(float64)\n\t\t\t\ttt, _ := usage[\"total_tokens\"].(float64)\n\t\t\t\tif tt > 0 {\n\t\t\t\t\trecordTokenUsage(req.Model, int64(pt), int64(ct), int64(tt))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tw.Write([]byte(out))\n\t\t\tw.Write([]byte(\"\\n\"))\n\t\t\tif f, ok := w.(http.Flusher); ok {\n\t\t\t\tf.Flush()\n\t\t\t}\n\t\t}\n\t\tstats.log(r.Context(), \"chat\")\n\t\treturn\n\t}\n\n\trespBody, status, _, err := callOpenCodeAPI(r.Context(), upstreamBody, req.Model, auth)\n\tif err != nil || status < 200 || status >= 300 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(status)\n\t\tif len(respBody) > 0 {\n\t\t\tw.Write(respBody)\n\t\t} else {\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\"error\": map[string]any{\"message\": \"upstream error\", \"type\": \"upstream_error\"}})\n\t\t}\n\t\treturn\n\t}\n\toutBody := respBody\n\tconvertedResp, err := convertResponse(respBody, keepReasoning)\n\tif err == nil {\n\t\toutBody = convertedResp\n\t}\n\tresult := summarizeChatResult(outBody)\n\tif !keepReasoning {\n\t\tvar before map[string]any\n\t\tif json.Unmarshal(respBody, &before) == nil {\n\t\t\tif choices, ok := before[\"choices\"].([]any); ok && len(choices) > 0 {\n\t\t\t\tif choice, ok := choices[0].(map[string]any); ok {\n\t\t\t\t\tif msg, ok := choice[\"message\"].(map[string]any); ok {\n\t\t\t\t\t\tcontent, _ := msg[\"content\"].(string)\n\t\t\t\t\t\trc, _ := msg[\"reasoning_content\"].(string)\n\t\t\t\t\t\tif content == \"\" && rc != \"\" {\n\t\t\t\t\t\t\tresult[\"promoted_reasoning\"] = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tlogRequestResult(r.Context(), result)\n\t// Record token usage\n\tvar usageResp map[string]any\n\tif json.Unmarshal(respBody, &usageResp) == nil {\n\t\tif u, ok := usageResp[\"usage\"].(map[string]any); ok {\n\t\t\tpt, _ := u[\"prompt_tokens\"].(float64)\n\t\t\tct, _ := u[\"completion_tokens\"].(float64)\n\t\t\ttt, _ := u[\"total_tokens\"].(float64)\n\t\t\tif tt > 0 {\n\t\t\t\trecordTokenUsage(req.Model, int64(pt), int64(ct), int64(tt))\n\t\t\t}\n\t\t}\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(status)\n\tw.Write(outBody)\n}\n\n// ======================== Models Handler ========================\n\nfunc listModelsHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tmodelMu.RLock()\n\tloaded, models := modelsLoaded, modelsCache\n\tmodelMu.RUnlock()\n\tif !loaded || len(models) == 0 {\n\t\tfetched, err := fetchModels()\n\t\tif err == nil && len(fetched) > 0 {\n\t\t\tmodelMu.Lock()\n\t\t\tmodelsCache = fetched\n\t\t\tmodelsLoaded = true\n\t\t\tmodels = modelsCache\n\t\t\tmodelMu.Unlock()\n\t\t}\n\t}\n\tif len(models) == 0 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\t\"error\": \"无法获取模型列表,请检查上游服务是否可用\",\n\t\t})\n\t\treturn\n\t}\n\t// 保存别名快照;目录权限仍按真实上游模型判断,最后再替换为客户端可见名称。\n\tconfigMu.RLock()\n\taliases := make(map[string]string, len(modelAlias))\n\tfor alias, upstream := range modelAlias {\n\t\taliases[alias] = upstream\n\t}\n\tconfigMu.RUnlock()\n\n\tauth := extractUpstreamAuth(r)\n\tvar combinedModels []ModelInfo\n\tswitch {\n\tcase auth.shouldUseGoCatalog():\n\t\tmodelMu.RLock()\n\t\tcombinedModels = make([]ModelInfo, 0, len(models)+len(goModelsCache))\n\t\tfor _, model := range models {\n\t\t\tif isFreeModel(model.ID) {\n\t\t\t\tcombinedModels = append(combinedModels, model)\n\t\t\t}\n\t\t}\n\t\tfor _, goModel := range goModelsCache {\n\t\t\tif !containsModelWithID(combinedModels, goModel.ID) {\n\t\t\t\tcombinedModels = append(combinedModels, goModel)\n\t\t\t}\n\t\t}\n\t\tmodelMu.RUnlock()\n\tcase auth.Mode == AuthRoutePublic:\n\t\tcombinedModels = models\n\t\tfiltered := make([]ModelInfo, 0, len(combinedModels))\n\t\tfor _, m := range combinedModels {\n\t\t\tif isFreeModel(m.ID) {\n\t\t\t\tfiltered = append(filtered, m)\n\t\t\t}\n\t\t}\n\t\tif len(filtered) > 0 {\n\t\t\tcombinedModels = filtered\n\t\t}\n\tdefault:\n\t\tcombinedModels = models\n\t}\n\tallModels := replaceModelIDsWithAliases(combinedModels, aliases)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"object\": \"list\",\n\t\t\"data\": allModels,\n\t})\n}\n\nfunc replaceModelIDsWithAliases(models []ModelInfo, aliases map[string]string) []ModelInfo {\n\taliasesByUpstream := make(map[string][]string, len(aliases))\n\tfor alias, upstream := range aliases {\n\t\talias = strings.TrimSpace(alias)\n\t\tupstream = strings.TrimSpace(upstream)\n\t\tif alias == \"\" || upstream == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\taliasesByUpstream[upstream] = append(aliasesByUpstream[upstream], alias)\n\t}\n\tfor upstream := range aliasesByUpstream {\n\t\tsort.Strings(aliasesByUpstream[upstream])\n\t}\n\n\tresult := make([]ModelInfo, 0, len(models))\n\tseen := make(map[string]struct{}, len(models))\n\tfor _, model := range models {\n\t\tvisibleIDs := aliasesByUpstream[model.ID]\n\t\tif len(visibleIDs) == 0 {\n\t\t\tvisibleIDs = []string{publicFacingModelID(model.ID)}\n\t\t}\n\t\tfor _, visibleID := range visibleIDs {\n\t\t\tif _, exists := seen[visibleID]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvisibleModel := model\n\t\t\tvisibleModel.ID = visibleID\n\t\t\tif visibleID != model.ID {\n\t\t\t\tvisibleModel.OwnedBy = \"alias\"\n\t\t\t}\n\t\t\tresult = append(result, visibleModel)\n\t\t\tseen[visibleID] = struct{}{}\n\t\t}\n\t}\n\treturn result\n}\n\n// ======================== Claude Messages API ========================\n\nfunc extractClaudeSystemText(system any) string {\n\tif system == nil {\n\t\treturn \"\"\n\t}\n\tswitch v := system.(type) {\n\tcase string:\n\t\treturn v\n\tcase []any:\n\t\tvar parts []string\n\t\tfor _, item := range v {\n\t\t\tif block, ok := item.(map[string]any); ok {\n\t\t\t\tif block[\"type\"] == \"text\" {\n\t\t\t\t\tif text, ok := block[\"text\"].(string); ok {\n\t\t\t\t\t\tparts = append(parts, text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn strings.Join(parts, \"\\n\")\n\tdefault:\n\t\tb, _ := json.Marshal(v)\n\t\treturn string(b)\n\t}\n}\n\nfunc cleanJsonSchema(schema any) any {\n\tm, ok := schema.(map[string]any)\n\tif !ok {\n\t\treturn schema\n\t}\n\tclean := make(map[string]any, len(m))\n\tfor k, v := range m {\n\t\t// Annotation-only keys are omitted for upstream compatibility. Constraint\n\t\t// keys such as additionalProperties and format are preserved.\n\t\tif k == \"$schema\" || k == \"title\" || k == \"examples\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch child := v.(type) {\n\t\tcase map[string]any:\n\t\t\tclean[k] = cleanJsonSchema(child)\n\t\tcase []any:\n\t\t\tcopyArray := make([]any, len(child))\n\t\t\tfor i, elem := range child {\n\t\t\t\tcopyArray[i] = cleanJsonSchema(elem)\n\t\t\t}\n\t\t\tclean[k] = copyArray\n\t\tdefault:\n\t\t\tclean[k] = v\n\t\t}\n\t}\n\treturn clean\n}\n\nfunc claudeImageBlockToOpenAI(block map[string]any) (map[string]any, bool) {\n\tsource, _ := block[\"source\"].(map[string]any)\n\tif source == nil {\n\t\treturn nil, false\n\t}\n\tsrcType, _ := source[\"type\"].(string)\n\tmediaType, _ := source[\"media_type\"].(string)\n\tdata, _ := source[\"data\"].(string)\n\turl, _ := source[\"url\"].(string)\n\tif srcType == \"url\" && url != \"\" {\n\t\treturn map[string]any{\"type\": \"image_url\", \"image_url\": map[string]string{\"url\": url}}, true\n\t}\n\tif srcType == \"base64\" && data != \"\" {\n\t\tif mediaType == \"\" {\n\t\t\tmediaType = \"image/png\"\n\t\t}\n\t\treturn map[string]any{\n\t\t\t\"type\": \"image_url\",\n\t\t\t\"image_url\": map[string]string{\n\t\t\t\t\"url\": \"data:\" + mediaType + \";base64,\" + data,\n\t\t\t},\n\t\t}, true\n\t}\n\treturn nil, false\n}\n\nfunc extractClaudeContentText(content any) string {\n\tswitch c := content.(type) {\n\tcase string:\n\t\treturn c\n\tcase []any:\n\t\tvar parts []string\n\t\tfor _, item := range c {\n\t\t\tblock, ok := item.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif block[\"type\"] == \"text\" {\n\t\t\t\tif text, ok := block[\"text\"].(string); ok && text != \"\" {\n\t\t\t\t\tparts = append(parts, text)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn strings.Join(parts, \"\\n\")\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc claudeToOpenAIMessages(claudeMsgs []ClaudeMessage, system any) []Message {\n\tvar systemParts []string\n\tif sysText := extractClaudeSystemText(system); sysText != \"\" {\n\t\tsystemParts = append(systemParts, sysText)\n\t}\n\n\tvar body []Message\n\tfor _, msg := range claudeMsgs {\n\t\tif msg.Role == \"system\" {\n\t\t\tif text := extractClaudeContentText(msg.Content); text != \"\" {\n\t\t\t\tsystemParts = append(systemParts, text)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tswitch content := msg.Content.(type) {\n\t\tcase string:\n\t\t\tbody = append(body, Message{Role: msg.Role, Content: content})\n\t\tcase []any:\n\t\t\tvar orderedContent []any\n\t\t\tvar reasoningParts []string\n\t\t\tvar toolCalls []ToolCall\n\t\t\tvar toolResults []Message\n\t\t\tvar followupImages []any\n\t\t\tfor _, item := range content {\n\t\t\t\tblock, ok := item.(map[string]any)\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tblockType, _ := block[\"type\"].(string)\n\t\t\t\tswitch blockType {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tif text, ok := block[\"text\"].(string); ok && text != \"\" {\n\t\t\t\t\t\torderedContent = append(orderedContent, map[string]any{\"type\": \"text\", \"text\": text})\n\t\t\t\t\t}\n\t\t\t\tcase \"image\":\n\t\t\t\t\tif part, ok := claudeImageBlockToOpenAI(block); ok {\n\t\t\t\t\t\torderedContent = append(orderedContent, part)\n\t\t\t\t\t}\n\t\t\t\tcase \"thinking\":\n\t\t\t\t\tif thinking, ok := block[\"thinking\"].(string); ok && thinking != \"\" {\n\t\t\t\t\t\treasoningParts = append(reasoningParts, thinking)\n\t\t\t\t\t}\n\t\t\t\tcase \"tool_use\":\n\t\t\t\t\tid, _ := block[\"id\"].(string)\n\t\t\t\t\tname, _ := block[\"name\"].(string)\n\t\t\t\t\tvar args string\n\t\t\t\t\tswitch input := block[\"input\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\targs = input\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif input != nil {\n\t\t\t\t\t\t\tb, _ := json.Marshal(input)\n\t\t\t\t\t\t\targs = string(b)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif args == \"\" {\n\t\t\t\t\t\targs = \"{}\"\n\t\t\t\t\t}\n\t\t\t\t\ttoolCalls = append(toolCalls, ToolCall{\n\t\t\t\t\t\tID: id,\n\t\t\t\t\t\tType: \"function\",\n\t\t\t\t\t\tFunction: FunctionCall{\n\t\t\t\t\t\t\tName: name,\n\t\t\t\t\t\t\tArguments: args,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\tcase \"tool_result\":\n\t\t\t\t\ttoolUseID, _ := block[\"tool_use_id\"].(string)\n\t\t\t\t\tvar resultText string\n\t\t\t\t\tvar imageParts []any\n\t\t\t\t\tswitch c := block[\"content\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tresultText = c\n\t\t\t\t\tcase []any:\n\t\t\t\t\t\tvar parts []string\n\t\t\t\t\t\tfor _, p := range c {\n\t\t\t\t\t\t\tpb, ok := p.(map[string]any)\n\t\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tswitch pb[\"type\"] {\n\t\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\t\tif t, ok := pb[\"text\"].(string); ok {\n\t\t\t\t\t\t\t\t\tparts = append(parts, t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcase \"image\":\n\t\t\t\t\t\t\t\tif part, ok := claudeImageBlockToOpenAI(pb); ok {\n\t\t\t\t\t\t\t\t\timageParts = append(imageParts, part)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresultText = strings.Join(parts, \"\\n\")\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tif c != nil {\n\t\t\t\t\t\t\tb, _ := json.Marshal(c)\n\t\t\t\t\t\t\tresultText = string(b)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif len(imageParts) > 0 {\n\t\t\t\t\t\tif resultText != \"\" {\n\t\t\t\t\t\t\tresultText += \"\\n\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresultText += \"[image attached]\"\n\t\t\t\t\t\tfollowupImages = append(followupImages, imageParts...)\n\t\t\t\t\t}\n\t\t\t\t\tif isError, _ := block[\"is_error\"].(bool); isError {\n\t\t\t\t\t\tresultText = \"Error: \" + resultText\n\t\t\t\t\t}\n\t\t\t\t\ttoolResults = append(toolResults, Message{\n\t\t\t\t\t\tRole: \"tool\",\n\t\t\t\t\t\tToolCallID: toolUseID,\n\t\t\t\t\t\tContent: resultText,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tom := Message{Role: msg.Role}\n\t\t\tif len(orderedContent) > 0 {\n\t\t\t\tom.Content = orderedContent\n\t\t\t} else if len(toolCalls) == 0 {\n\t\t\t\tom.Content = \"\"\n\t\t\t}\n\t\t\tif len(reasoningParts) > 0 {\n\t\t\t\trc := strings.Join(reasoningParts, \"\\n\")\n\t\t\t\tom.ReasoningContent = &rc\n\t\t\t}\n\t\t\tif len(toolCalls) > 0 {\n\t\t\t\tom.ToolCalls = toolCalls\n\t\t\t}\n\t\t\t// Anthropic requires tool_result blocks to precede ordinary user\n\t\t\t// content. Preserve that order when translating them to Chat\n\t\t\t// Completions' separate tool messages.\n\t\t\tif msg.Role == \"user\" {\n\t\t\t\tbody = append(body, toolResults...)\n\t\t\t\tif len(followupImages) > 0 {\n\t\t\t\t\tbody = append(body, Message{Role: \"user\", Content: followupImages})\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(orderedContent) > 0 || len(reasoningParts) > 0 || len(toolCalls) > 0 || len(toolResults) == 0 {\n\t\t\t\tbody = append(body, om)\n\t\t\t}\n\t\t\tif msg.Role != \"user\" {\n\t\t\t\tbody = append(body, toolResults...)\n\t\t\t\tif len(followupImages) > 0 {\n\t\t\t\t\tbody = append(body, Message{Role: \"user\", Content: followupImages})\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\tb, _ := json.Marshal(content)\n\t\t\tbody = append(body, Message{Role: msg.Role, Content: string(b)})\n\t\t}\n\t}\n\n\tvar messages []Message\n\tif len(systemParts) > 0 {\n\t\tmessages = append(messages, Message{Role: \"system\", Content: strings.Join(systemParts, \"\\n\\n\")})\n\t}\n\tmessages = append(messages, body...)\n\treturn messages\n}\n\nfunc claudeToOpenAITools(claudeTools []ClaudeTool) ([]Tool, []string) {\n\ttools := make([]Tool, 0, len(claudeTools))\n\tvar skipped []string\n\tfor _, ct := range claudeTools {\n\t\t// Server tools (web_search_*, etc.) carry a vendor type and no client schema.\n\t\t// Emitting them as empty function tools would invite bogus model calls.\n\t\tif ct.Type != \"\" && ct.InputSchema == nil {\n\t\t\tskipped = append(skipped, ct.Name)\n\t\t\tcontinue\n\t\t}\n\t\tparams := ct.InputSchema\n\t\tif params == nil {\n\t\t\tparams = map[string]any{\"type\": \"object\", \"properties\": map[string]any{}}\n\t\t}\n\t\tparams = cleanJsonSchema(params)\n\t\tparamsMap, ok := params.(map[string]any)\n\t\tif !ok {\n\t\t\tparamsMap = map[string]any{\"type\": \"object\", \"properties\": map[string]any{}}\n\t\t}\n\t\ttools = append(tools, Tool{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunction{\n\t\t\t\tName: ct.Name,\n\t\t\t\tDescription: ct.Description,\n\t\t\t\tParameters: paramsMap,\n\t\t\t},\n\t\t})\n\t}\n\treturn tools, skipped\n}\n\nfunc countClaudeSystemParts(msgs []ClaudeMessage, system any) int {\n\tn := 0\n\tif extractClaudeSystemText(system) != \"\" {\n\t\tn++\n\t}\n\tfor _, msg := range msgs {\n\t\tif msg.Role == \"system\" && extractClaudeContentText(msg.Content) != \"\" {\n\t\t\tn++\n\t\t}\n\t}\n\treturn n\n}\n\nfunc countAnthropicBetas(header string) int {\n\theader = strings.TrimSpace(header)\n\tif header == \"\" {\n\t\treturn 0\n\t}\n\tn := 0\n\tfor _, part := range strings.Split(header, \",\") {\n\t\tif strings.TrimSpace(part) != \"\" {\n\t\t\tn++\n\t\t}\n\t}\n\treturn n\n}\n\nfunc countCacheControlInValue(v any) int {\n\tswitch x := v.(type) {\n\tcase map[string]any:\n\t\tn := 0\n\t\tif _, ok := x[\"cache_control\"]; ok {\n\t\t\tn++\n\t\t}\n\t\tfor _, child := range x {\n\t\t\tn += countCacheControlInValue(child)\n\t\t}\n\t\treturn n\n\tcase []any:\n\t\tn := 0\n\t\tfor _, child := range x {\n\t\t\tn += countCacheControlInValue(child)\n\t\t}\n\t\treturn n\n\tdefault:\n\t\treturn 0\n\t}\n}\n\nfunc countClaudeCacheControlBlocks(req ClaudeRequest) int {\n\tn := countCacheControlInValue(req.System)\n\tfor _, msg := range req.Messages {\n\t\tn += countCacheControlInValue(msg.Content)\n\t}\n\tfor _, tool := range req.Tools {\n\t\tn += countCacheControlInValue(tool.InputSchema)\n\t}\n\treturn n\n}\n\nvar claudeUnsupportedBlockTypes = map[string]struct{}{\n\t\"redacted_thinking\": {},\n\t\"document\": {},\n\t\"search_result\": {},\n\t\"server_tool_use\": {},\n\t\"web_search_tool_result\": {},\n\t\"container_upload\": {},\n}\n\nfunc scanClaudeUnsupportedBlocks(msgs []ClaudeMessage) map[string]int {\n\tcounts := map[string]int{}\n\tvar walk func(any)\n\twalk = func(v any) {\n\t\tswitch x := v.(type) {\n\t\tcase map[string]any:\n\t\t\tif t, _ := x[\"type\"].(string); t != \"\" {\n\t\t\t\tif _, ok := claudeUnsupportedBlockTypes[t]; ok {\n\t\t\t\t\tcounts[t]++\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, child := range x {\n\t\t\t\twalk(child)\n\t\t\t}\n\t\tcase []any:\n\t\t\tfor _, child := range x {\n\t\t\t\twalk(child)\n\t\t\t}\n\t\t}\n\t}\n\tfor _, msg := range msgs {\n\t\twalk(msg.Content)\n\t}\n\tif len(counts) == 0 {\n\t\treturn nil\n\t}\n\treturn counts\n}\n\nfunc openAIToClaudeResponse(chatBody []byte, model string, wantReasoning bool) []byte {\n\tvar chat struct {\n\t\tID string `json:\"id\"`\n\t\tModel string `json:\"model\"`\n\t\tCreated int64 `json:\"created\"`\n\t\tChoices []struct {\n\t\t\tMessage struct {\n\t\t\t\tContent string `json:\"content\"`\n\t\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\t\tToolCalls []ToolCall `json:\"tool_calls\"`\n\t\t\t} `json:\"message\"`\n\t\t\tFinishReason string `json:\"finish_reason\"`\n\t\t} `json:\"choices\"`\n\t\tUsage map[string]any `json:\"usage\"`\n\t}\n\tif err := json.Unmarshal(chatBody, &chat); err != nil {\n\t\tslog.Warn(\"openAIToClaudeResponse unmarshal failed\", \"error\", err)\n\t}\n\n\tcontent := []ClaudeContent{}\n\tstopReason := \"end_turn\"\n\n\tif len(chat.Choices) > 0 {\n\t\tmsg := chat.Choices[0].Message\n\t\tfr := chat.Choices[0].FinishReason\n\t\tif wantReasoning && msg.ReasoningContent != \"\" {\n\t\t\tcontent = append(content, ClaudeContent{\n\t\t\t\tType: \"thinking\",\n\t\t\t\tThinking: msg.ReasoningContent,\n\t\t\t})\n\t\t}\n\t\ttext := msg.Content\n\t\t// #37635: Go gateway often puts the whole answer in reasoning_content.\n\t\t// Promote to text when content is empty so Claude Code does not see an\n\t\t// empty end_turn and exit the agent loop.\n\t\tif text == \"\" && msg.ReasoningContent != \"\" && len(msg.ToolCalls) == 0 {\n\t\t\ttext = msg.ReasoningContent\n\t\t}\n\t\tif text != \"\" {\n\t\t\tcontent = append(content, ClaudeContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: text,\n\t\t\t})\n\t\t}\n\t\tfor _, tc := range msg.ToolCalls {\n\t\t\tvar input any\n\t\t\tjson.Unmarshal([]byte(tc.Function.Arguments), &input)\n\t\t\tif input == nil {\n\t\t\t\tinput = map[string]any{}\n\t\t\t}\n\t\t\tcontent = append(content, ClaudeContent{\n\t\t\t\tType: \"tool_use\",\n\t\t\t\tID: tc.ID,\n\t\t\t\tName: tc.Function.Name,\n\t\t\t\tInput: input,\n\t\t\t})\n\t\t}\n\t\tswitch fr {\n\t\tcase \"stop\":\n\t\t\tstopReason = \"end_turn\"\n\t\tcase \"length\":\n\t\t\tstopReason = \"max_tokens\"\n\t\tcase \"tool_calls\", \"function_call\":\n\t\t\tstopReason = \"tool_use\"\n\t\tcase \"content_filter\":\n\t\t\tstopReason = \"refusal\"\n\t\t}\n\t}\n\n\tif len(content) == 0 {\n\t\tcontent = append(content, ClaudeContent{Type: \"text\", Text: \"\"})\n\t}\n\n\tresp := ClaudeResponse{\n\t\tID: fmt.Sprintf(\"msg_%s\", randomString(24)),\n\t\tType: \"message\",\n\t\tRole: \"assistant\",\n\t\tContent: content,\n\t\tModel: model,\n\t\tStopReason: stopReason,\n\t}\n\tif chat.Usage != nil {\n\t\tresp.Usage = buildClaudeMessageUsage(chat.Usage)\n\t}\n\tresult, _ := json.Marshal(resp)\n\treturn result\n}\n\nfunc toFloat64(v any) float64 {\n\tswitch n := v.(type) {\n\tcase float64:\n\t\treturn n\n\tcase int:\n\t\treturn float64(n)\n\tcase int64:\n\t\treturn float64(n)\n\tdefault:\n\t\treturn 0\n\t}\n}\n\nfunc usageIntField(fields map[string]any, key string) (int, bool) {\n\tif fields == nil {\n\t\treturn 0, false\n\t}\n\tvalue, ok := fields[key]\n\tif !ok || value == nil {\n\t\treturn 0, false\n\t}\n\treturn int(toFloat64(value)), true\n}\n\nfunc usageMapField(fields map[string]any, key string) (map[string]any, bool) {\n\tif fields == nil {\n\t\treturn nil, false\n\t}\n\tvalue, ok := fields[key]\n\tif !ok || value == nil {\n\t\treturn nil, false\n\t}\n\tmapped, ok := value.(map[string]any)\n\treturn mapped, ok\n}\n\nfunc buildClaudeUsageCore(upstreamUsage map[string]any) ClaudeUsage {\n\tif len(upstreamUsage) == 0 {\n\t\treturn nil\n\t}\n\n\tusage := ClaudeUsage{}\n\tif value, ok := usageIntField(upstreamUsage, \"prompt_tokens\"); ok {\n\t\tusage[\"input_tokens\"] = value\n\t}\n\tif value, ok := usageIntField(upstreamUsage, \"input_tokens\"); ok {\n\t\tif _, exists := usage[\"input_tokens\"]; !exists {\n\t\t\tusage[\"input_tokens\"] = value\n\t\t}\n\t}\n\tif value, ok := usageIntField(upstreamUsage, \"completion_tokens\"); ok {\n\t\tusage[\"output_tokens\"] = value\n\t}\n\tif value, ok := usageIntField(upstreamUsage, \"output_tokens\"); ok {\n\t\tif _, exists := usage[\"output_tokens\"]; !exists {\n\t\t\tusage[\"output_tokens\"] = value\n\t\t}\n\t}\n\tif value, ok := usageIntField(upstreamUsage, \"cache_creation_input_tokens\"); ok {\n\t\tusage[\"cache_creation_input_tokens\"] = value\n\t}\n\tif value, ok := usageIntField(upstreamUsage, \"cache_read_input_tokens\"); ok {\n\t\tusage[\"cache_read_input_tokens\"] = value\n\t} else if promptDetails, ok := usageMapField(upstreamUsage, \"prompt_tokens_details\"); ok {\n\t\tif value, ok := usageIntField(promptDetails, \"cached_tokens\"); ok {\n\t\t\tusage[\"cache_read_input_tokens\"] = value\n\t\t}\n\t}\n\tif outputDetails, ok := usageMapField(upstreamUsage, \"output_tokens_details\"); ok {\n\t\tusage[\"output_tokens_details\"] = outputDetails\n\t} else if outputDetails, ok := usageMapField(upstreamUsage, \"completion_tokens_details\"); ok {\n\t\tusage[\"output_tokens_details\"] = outputDetails\n\t}\n\tif serverToolUse, ok := usageMapField(upstreamUsage, \"server_tool_use\"); ok {\n\t\tusage[\"server_tool_use\"] = serverToolUse\n\t}\n\tif len(usage) == 0 {\n\t\treturn nil\n\t}\n\treturn usage\n}\n\nfunc buildClaudeMessageUsage(upstreamUsage map[string]any) ClaudeUsage {\n\tusage := buildClaudeUsageCore(upstreamUsage)\n\tif usage == nil {\n\t\tusage = ClaudeUsage{}\n\t}\n\tif cacheCreation, ok := usageMapField(upstreamUsage, \"cache_creation\"); ok {\n\t\tusage[\"cache_creation\"] = cacheCreation\n\t}\n\tif serviceTier, ok := upstreamUsage[\"service_tier\"].(string); ok && serviceTier != \"\" {\n\t\tusage[\"service_tier\"] = serviceTier\n\t}\n\tif inferenceGeo, ok := upstreamUsage[\"inference_geo\"].(string); ok && inferenceGeo != \"\" {\n\t\tusage[\"inference_geo\"] = inferenceGeo\n\t}\n\tif _, exists := usage[\"input_tokens\"]; !exists {\n\t\tusage[\"input_tokens\"] = 0\n\t}\n\tif _, exists := usage[\"output_tokens\"]; !exists {\n\t\tusage[\"output_tokens\"] = 0\n\t}\n\treturn usage\n}\n\nfunc buildClaudeDeltaUsage(upstreamUsage map[string]any) ClaudeUsage {\n\tusage := buildClaudeUsageCore(upstreamUsage)\n\tif usage == nil {\n\t\tusage = ClaudeUsage{}\n\t}\n\tif _, exists := usage[\"output_tokens\"]; !exists {\n\t\tusage[\"output_tokens\"] = 0\n\t}\n\treturn usage\n}\n\nfunc claudeMessagesHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\tauth := extractUpstreamAuth(r)\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcnt := requestCount.Add(1)\n\tmaybeLogBodySummary(r.Context(), \"claude messages request body\", body)\n\t_ = cnt\n\n\tvar claudeReq ClaudeRequest\n\tif err := json.Unmarshal(body, &claudeReq); err != nil {\n\t\thttp.Error(w, `{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Invalid JSON\"}}`, http.StatusBadRequest)\n\t\treturn\n\t}\n\tmodelIn := claudeReq.Model\n\tclaudeReq.Model = resolveModel(claudeReq.Model)\n\n\t// 多模态路由\n\n\tchatReq, skippedServerTools := convertClaudeRequest(claudeReq)\n\tchatReq.Messages = fixToolCallGaps(chatReq.Messages)\n\tif claudeReq.Stream {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"stream_options\"] = map[string]any{\"include_usage\": true}\n\t}\n\n\t// Keep CoT by default so Claude Code still sees thinking blocks. Only drop\n\t// reasoning when force-disabled or the client explicitly disables thinking.\n\t// Empty-reply protection is handled by promoteMisplacedReasoning (!keep)\n\t// and emitEmptyTextFallback (keep + no text/tool_use).\n\twantReasoning := !getForceDisableThinking()\n\tif claudeReq.Thinking != nil && isThinkingDisabled(claudeReq.Thinking) {\n\t\twantReasoning = false\n\t}\n\tkeepReasoning := wantReasoning\n\tchatReq.Messages = ensureReasoningContent(chatReq.Messages, keepReasoning)\n\n\teffortIn := chatReq.ReasoningEffort\n\tif effortIn == \"\" && !isThinkingDisabled(claudeReq.Thinking) {\n\t\teffortIn = reasoningEffortFromThinking(claudeReq.Thinking)\n\t}\n\tupstreamSurface := \"zen\"\n\tif auth.shouldUseGoEndpoint(chatReq.Model) {\n\t\tupstreamSurface = \"go\"\n\t}\n\tsystemMerged := countClaudeSystemParts(claudeReq.Messages, claudeReq.System) > 1\n\tplan := map[string]any{\n\t\t\"protocol\": \"claude\",\n\t\t\"model_in\": modelIn,\n\t\t\"model_resolved\": chatReq.Model,\n\t\t\"auth_mode\": authModeString(auth.Mode),\n\t\t\"auth_source\": auth.Source,\n\t\t\"has_key\": auth.Token != \"\",\n\t\t\"upstream_surface\": upstreamSurface,\n\t\t\"stream\": claudeReq.Stream,\n\t\t\"keep_reasoning\": keepReasoning,\n\t\t\"thinking\": thinkingState(claudeReq.Thinking),\n\t\t\"reasoning_effort_in\": effortIn,\n\t\t\"reasoning_effort_out\": mappedReasoningEffort(effortIn),\n\t\t\"tools_count\": len(chatReq.Tools),\n\t\t\"messages_count\": len(chatReq.Messages),\n\t\t\"system_merged\": systemMerged,\n\t\t\"context_management\": claudeReq.ContextManagement != nil,\n\t\t\"cache_control_blocks\": countClaudeCacheControlBlocks(claudeReq),\n\t\t\"client_beta_count\": countAnthropicBetas(r.Header.Get(\"anthropic-beta\")),\n\t\t\"unsupported_blocks\": scanClaudeUnsupportedBlocks(claudeReq.Messages),\n\t}\n\tif len(skippedServerTools) > 0 {\n\t\tplan[\"skipped_server_tools\"] = skippedServerTools\n\t}\n\tlogRequestPlan(r.Context(), plan)\n\n\tupstreamBody := buildUpstreamBody(&chatReq)\n\n\tif claudeReq.Stream {\n\t\tupResp, status, _, err := callOpenCodeAPIStream(r.Context(), upstreamBody, chatReq.Model, auth)\n\t\tif err != nil || status < 200 || status >= 300 {\n\t\t\terrResp := map[string]any{\n\t\t\t\t\"type\": \"error\",\n\t\t\t\t\"error\": map[string]string{\"type\": \"api_error\", \"message\": \"upstream error\"},\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(status)\n\t\t\tjson.NewEncoder(w).Encode(errResp)\n\t\t\treturn\n\t\t}\n\t\tdefer upResp.Close()\n\t\tclaudeStreamHandler(r.Context(), w, upResp, claudeReq.Model, keepReasoning)\n\t\treturn\n\t}\n\n\trespBody, status, _, err := callOpenCodeAPI(r.Context(), upstreamBody, chatReq.Model, auth)\n\tif err != nil || status < 200 || status >= 300 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(status)\n\t\tif len(respBody) > 0 {\n\t\t\tw.Write(respBody)\n\t\t} else {\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\"type\": \"error\", \"error\": map[string]string{\"type\": \"api_error\", \"message\": \"upstream error\"}})\n\t\t}\n\t\treturn\n\t}\n\n\tclaudeRespBody := openAIToClaudeResponse(respBody, claudeReq.Model, wantReasoning)\n\tresult := summarizeClaudeResult(claudeRespBody)\n\tif !wantReasoning {\n\t\tvar before map[string]any\n\t\tif json.Unmarshal(respBody, &before) == nil {\n\t\t\tif choices, ok := before[\"choices\"].([]any); ok && len(choices) > 0 {\n\t\t\t\tif choice, ok := choices[0].(map[string]any); ok {\n\t\t\t\t\tif msg, ok := choice[\"message\"].(map[string]any); ok {\n\t\t\t\t\t\tcontent, _ := msg[\"content\"].(string)\n\t\t\t\t\t\trc, _ := msg[\"reasoning_content\"].(string)\n\t\t\t\t\t\tif content == \"\" && rc != \"\" {\n\t\t\t\t\t\t\tresult[\"promoted_reasoning\"] = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tlogRequestResult(r.Context(), result)\n\n\t// Record token usage\n\tvar usageResp map[string]any\n\tif json.Unmarshal(respBody, &usageResp) == nil {\n\t\tif u, ok := usageResp[\"usage\"].(map[string]any); ok {\n\t\t\tpt, _ := u[\"prompt_tokens\"].(float64)\n\t\t\tct, _ := u[\"completion_tokens\"].(float64)\n\t\t\ttt, _ := u[\"total_tokens\"].(float64)\n\t\t\tif tt > 0 {\n\t\t\t\trecordTokenUsage(claudeReq.Model, int64(pt), int64(ct), int64(tt))\n\t\t\t}\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\tmaybeLogBodySummary(r.Context(), \"claude response body\", claudeRespBody)\n\tw.Write(claudeRespBody)\n}\n\nfunc claudeStreamHandler(ctx context.Context, w http.ResponseWriter, respBody io.ReadCloser, model string, keepReasoning bool) {\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tflusher, _ := w.(http.Flusher)\n\treader := bufio.NewReader(respBody)\n\tstats := &streamResultStats{start: time.Now()}\n\n\tmsgID := fmt.Sprintf(\"msg_%s\", randomString(24))\n\tblockIndex := 0\n\tthinkingBlockOpen := false\n\ttextBlockOpen := false\n\ttoolCallAccumulator := map[int]map[string]string{}\n\ttoolBlockIndices := map[int]int{}\n\ttoolCallOrder := []int{}\n\tmessageStartSent := false\n\tfinished := false\n\tstopReason := \"end_turn\"\n\tfullUsage := map[string]any{}\n\t// Accumulates reasoning when keepReasoning so we can fall back to a text\n\t// block if the stream never produces content/tool_use (#37635).\n\treasoningFallback := strings.Builder{}\n\tdefer func() {\n\t\tif len(fullUsage) > 0 {\n\t\t\tpt, _ := fullUsage[\"prompt_tokens\"].(float64)\n\t\t\tct, _ := fullUsage[\"completion_tokens\"].(float64)\n\t\t\ttt, _ := fullUsage[\"total_tokens\"].(float64)\n\t\t\tif tt > 0 {\n\t\t\t\trecordTokenUsage(model, int64(pt), int64(ct), int64(tt))\n\t\t\t}\n\t\t}\n\t\tstats.toolCallCount = len(toolCallOrder)\n\t\tstats.log(ctx, \"claude\")\n\t}()\n\n\temitClaudeEvent := func(event string, data any) {\n\t\tjsonData, err := json.Marshal(data)\n\t\tif err != nil {\n\t\t\treqLogger(ctx).Error(\"marshal SSE event failed\", \"error\", err)\n\t\t\treturn\n\t\t}\n\t\tw.Write([]byte(\"event: \" + event + \"\\n\"))\n\t\tw.Write([]byte(\"data: \" + string(jsonData) + \"\\n\\n\"))\n\t\tif flusher != nil {\n\t\t\tflusher.Flush()\n\t\t}\n\t}\n\n\tcloseThinkingBlock := func() {\n\t\tif !thinkingBlockOpen {\n\t\t\treturn\n\t\t}\n\t\temitClaudeEvent(\"content_block_stop\", map[string]any{\n\t\t\t\"type\": \"content_block_stop\",\n\t\t\t\"index\": blockIndex - 1,\n\t\t\t\"content_block\": map[string]any{\"type\": \"thinking\"},\n\t\t})\n\t\tthinkingBlockOpen = false\n\t}\n\n\tcloseTextBlock := func() {\n\t\tif !textBlockOpen {\n\t\t\treturn\n\t\t}\n\t\temitClaudeEvent(\"content_block_stop\", map[string]any{\n\t\t\t\"type\": \"content_block_stop\",\n\t\t\t\"index\": blockIndex - 1,\n\t\t\t\"content_block\": map[string]any{\"type\": \"text\"},\n\t\t})\n\t\ttextBlockOpen = false\n\t}\n\n\tensureMessageStart := func() {\n\t\tif messageStartSent {\n\t\t\treturn\n\t\t}\n\t\tmessageStartSent = true\n\t\temitClaudeEvent(\"message_start\", map[string]any{\n\t\t\t\"type\": \"message_start\",\n\t\t\t\"message\": map[string]any{\n\t\t\t\t\"id\": msgID,\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": []any{},\n\t\t\t\t\"model\": model,\n\t\t\t\t\"stop_reason\": nil,\n\t\t\t\t\"usage\": buildClaudeMessageUsage(fullUsage),\n\t\t\t},\n\t\t})\n\t\temitClaudeEvent(\"ping\", map[string]any{\"type\": \"ping\"})\n\t}\n\n\temitTextDelta := func(contentStr string) {\n\t\tif contentStr == \"\" {\n\t\t\treturn\n\t\t}\n\t\tstats.textChars += len(contentStr)\n\t\tcloseThinkingBlock()\n\t\tif !textBlockOpen {\n\t\t\temitClaudeEvent(\"content_block_start\", map[string]any{\n\t\t\t\t\"type\": \"content_block_start\",\n\t\t\t\t\"index\": blockIndex,\n\t\t\t\t\"content_block\": map[string]any{\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"text\": \"\",\n\t\t\t\t},\n\t\t\t})\n\t\t\ttextBlockOpen = true\n\t\t\tblockIndex++\n\t\t}\n\t\temitClaudeEvent(\"content_block_delta\", map[string]any{\n\t\t\t\"type\": \"content_block_delta\",\n\t\t\t\"index\": blockIndex - 1,\n\t\t\t\"delta\": map[string]any{\n\t\t\t\t\"type\": \"text_delta\",\n\t\t\t\t\"text\": contentStr,\n\t\t\t},\n\t\t})\n\t}\n\n\temitEmptyTextFallback := func() {\n\t\tif textBlockOpen || len(toolCallOrder) > 0 {\n\t\t\treturn\n\t\t}\n\t\tfallback := reasoningFallback.String()\n\t\tif fallback == \"\" {\n\t\t\treturn\n\t\t}\n\t\tstats.promotedReasoning = true\n\t\temitTextDelta(fallback)\n\t}\n\n\tfinalizeContentBlocks := func() {\n\t\temitEmptyTextFallback()\n\t\tcloseThinkingBlock()\n\t\tcloseTextBlock()\n\t\tfor _, idx := range toolCallOrder {\n\t\t\tacc := toolCallAccumulator[idx]\n\t\t\temitClaudeEvent(\"content_block_stop\", map[string]any{\n\t\t\t\t\"type\": \"content_block_stop\",\n\t\t\t\t\"index\": toolBlockIndices[idx],\n\t\t\t\t\"content_block\": map[string]any{\n\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\"id\": acc[\"id\"],\n\t\t\t\t\t\"name\": acc[\"name\"],\n\t\t\t\t\t\"input\": map[string]any{},\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\tfor {\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treqLogger(ctx).Error(\"stream read error\", \"error\", err)\n\t\t\tbreak\n\t\t}\n\n\t\ttrimmed := strings.TrimSpace(line)\n\t\tif trimmed == \"data: [DONE]\" || trimmed == \"[DONE]\" {\n\t\t\tstats.doneSeen = true\n\t\t\tbreak\n\t\t}\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar chunk map[string]any\n\t\tif err := json.Unmarshal([]byte(line[6:]), &chunk); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif usage, ok := chunk[\"usage\"].(map[string]any); ok {\n\t\t\tfullUsage = usage\n\t\t}\n\n\t\tchoices, ok := chunk[\"choices\"].([]any)\n\t\tif !ok || len(choices) == 0 {\n\t\t\t// Usage-only trailing chunk (OpenAI stream_options.include_usage).\n\t\t\tcontinue\n\t\t}\n\n\t\tchoice, _ := choices[0].(map[string]any)\n\t\tdelta, _ := choice[\"delta\"].(map[string]any)\n\t\tfinishReason, _ := choice[\"finish_reason\"].(string)\n\t\tstats.noteChunk()\n\n\t\tensureMessageStart()\n\n\t\t// After finish_reason, ignore further content deltas but keep reading\n\t\t// so a later usage-only chunk can populate fullUsage.\n\t\tif finished {\n\t\t\tcontinue\n\t\t}\n\n\t\tif rc, ok := delta[\"reasoning_content\"]; ok {\n\t\t\trcStr, _ := rc.(string)\n\t\t\tif rcStr != \"\" {\n\t\t\t\tstats.reasoningChars += len(rcStr)\n\t\t\t\tif keepReasoning {\n\t\t\t\t\treasoningFallback.WriteString(rcStr)\n\t\t\t\t\tcloseTextBlock()\n\t\t\t\t\tif !thinkingBlockOpen {\n\t\t\t\t\t\temitClaudeEvent(\"content_block_start\", map[string]any{\n\t\t\t\t\t\t\t\"type\": \"content_block_start\",\n\t\t\t\t\t\t\t\"index\": blockIndex,\n\t\t\t\t\t\t\t\"content_block\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\": \"thinking\",\n\t\t\t\t\t\t\t\t\"thinking\": \"\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t\tthinkingBlockOpen = true\n\t\t\t\t\t\tblockIndex++\n\t\t\t\t\t}\n\t\t\t\t\temitClaudeEvent(\"content_block_delta\", map[string]any{\n\t\t\t\t\t\t\"type\": \"content_block_delta\",\n\t\t\t\t\t\t\"index\": blockIndex - 1,\n\t\t\t\t\t\t\"delta\": map[string]any{\n\t\t\t\t\t\t\t\"type\": \"thinking_delta\",\n\t\t\t\t\t\t\t\"thinking\": rcStr,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\t// Thinking not requested: promote misplaced CoT to visible text (#37635).\n\t\t\t\t\tstats.promotedReasoning = true\n\t\t\t\t\temitTextDelta(rcStr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif c, ok := delta[\"content\"]; ok && c != nil {\n\t\t\tcontentStr, _ := c.(string)\n\t\t\tif contentStr != \"\" {\n\t\t\t\temitTextDelta(contentStr)\n\t\t\t}\n\t\t}\n\n\t\tif rawToolCalls, ok := delta[\"tool_calls\"].([]any); ok {\n\t\t\tfor _, rawTC := range rawToolCalls {\n\t\t\t\ttc, ok := rawTC.(map[string]any)\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tidxFloat, _ := tc[\"index\"].(float64)\n\t\t\t\tupstreamIndex := int(idxFloat)\n\n\t\t\t\tcloseThinkingBlock()\n\t\t\t\tcloseTextBlock()\n\n\t\t\t\tif _, exists := toolCallAccumulator[upstreamIndex]; !exists {\n\t\t\t\t\tcallID, _ := tc[\"id\"].(string)\n\t\t\t\t\tif callID == \"\" {\n\t\t\t\t\t\tcallID = \"toolu_\" + randomString(12)\n\t\t\t\t\t}\n\t\t\t\t\tfn, _ := tc[\"function\"].(map[string]any)\n\t\t\t\t\tname, _ := fn[\"name\"].(string)\n\t\t\t\t\ttoolCallAccumulator[upstreamIndex] = map[string]string{\n\t\t\t\t\t\t\"id\": callID,\n\t\t\t\t\t\t\"name\": name,\n\t\t\t\t\t\t\"args\": \"\",\n\t\t\t\t\t}\n\t\t\t\t\ttoolCallOrder = append(toolCallOrder, upstreamIndex)\n\t\t\t\t\ttoolBlockIndices[upstreamIndex] = blockIndex\n\t\t\t\t\temitClaudeEvent(\"content_block_start\", map[string]any{\n\t\t\t\t\t\t\"type\": \"content_block_start\",\n\t\t\t\t\t\t\"index\": blockIndex,\n\t\t\t\t\t\t\"content_block\": map[string]any{\n\t\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\t\"id\": callID,\n\t\t\t\t\t\t\t\"name\": name,\n\t\t\t\t\t\t\t\"input\": map[string]any{},\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t\tblockIndex++\n\t\t\t\t}\n\n\t\t\t\tfn, _ := tc[\"function\"].(map[string]any)\n\t\t\t\tif argDelta, ok := fn[\"arguments\"].(string); ok && argDelta != \"\" {\n\t\t\t\t\ttoolCallAccumulator[upstreamIndex][\"args\"] += argDelta\n\t\t\t\t\temitClaudeEvent(\"content_block_delta\", map[string]any{\n\t\t\t\t\t\t\"type\": \"content_block_delta\",\n\t\t\t\t\t\t\"index\": toolBlockIndices[upstreamIndex],\n\t\t\t\t\t\t\"delta\": map[string]any{\n\t\t\t\t\t\t\t\"type\": \"input_json_delta\",\n\t\t\t\t\t\t\t\"partial_json\": argDelta,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif finishReason == \"stop\" || finishReason == \"length\" || finishReason == \"tool_calls\" || finishReason == \"function_call\" || finishReason == \"content_filter\" {\n\t\t\tstats.finishReason = finishReason\n\t\t\tstats.sawFinish = true\n\t\t\tfinished = true\n\t\t\tfinalizeContentBlocks()\n\n\t\t\tstopReason = \"end_turn\"\n\t\t\tswitch finishReason {\n\t\t\tcase \"length\":\n\t\t\t\tstopReason = \"max_tokens\"\n\t\t\tcase \"tool_calls\", \"function_call\":\n\t\t\t\tstopReason = \"tool_use\"\n\t\t\tcase \"content_filter\":\n\t\t\t\tstopReason = \"refusal\"\n\t\t\t}\n\t\t\t// Do not emit message_delta/stop yet: OpenAI-compatible upstreams often\n\t\t\t// send the usage-only chunk after finish_reason when include_usage=true.\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tensureMessageStart()\n\tif !finished {\n\t\tfinalizeContentBlocks()\n\t}\n\temitClaudeEvent(\"message_delta\", map[string]any{\n\t\t\"type\": \"message_delta\",\n\t\t\"delta\": map[string]any{\"stop_reason\": stopReason},\n\t\t\"usage\": buildClaudeDeltaUsage(fullUsage),\n\t})\n\temitClaudeEvent(\"message_stop\", map[string]any{\"type\": \"message_stop\"})\n}\n\nfunc indexOfInt(slice []int, val int) int {\n\tfor i, v := range slice {\n\t\tif v == val {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn 0\n}\n\n// ======================== Responses API ========================\n\nfunc responsesInputToMessages(input any, instructions string) []Message {\n\tvar messages []Message\n\tif instructions != \"\" {\n\t\tmessages = append(messages, Message{Role: \"system\", Content: instructions})\n\t}\n\tswitch v := input.(type) {\n\tcase string:\n\t\tmessages = append(messages, Message{Role: \"user\", Content: v})\n\tcase []any:\n\t\tfunctionOutputs := collectFunctionOutputs(v)\n\t\tfor _, item := range v {\n\t\t\tswitch elem := item.(type) {\n\t\t\tcase string:\n\t\t\t\tmessages = append(messages, Message{Role: \"user\", Content: elem})\n\t\t\tcase map[string]any:\n\t\t\t\titemType, _ := elem[\"type\"].(string)\n\t\t\t\tswitch itemType {\n\t\t\t\tcase \"function_call\", \"tool_call\", \"apply_patch_call\", \"shell_call\":\n\t\t\t\t\tcallID, _ := elem[\"call_id\"].(string)\n\t\t\t\t\tif callID == \"\" {\n\t\t\t\t\t\tcallID, _ = elem[\"id\"].(string)\n\t\t\t\t\t}\n\t\t\t\t\tname, _ := elem[\"name\"].(string)\n\t\t\t\t\tif name == \"\" {\n\t\t\t\t\t\tswitch itemType {\n\t\t\t\t\t\tcase \"apply_patch_call\":\n\t\t\t\t\t\t\tname = \"apply_patch\"\n\t\t\t\t\t\tcase \"shell_call\":\n\t\t\t\t\t\t\tname = \"shell\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\targs, _ := elem[\"arguments\"].(string)\n\t\t\t\t\tif name == \"\" {\n\t\t\t\t\t\tif tu, ok := elem[\"tool_use\"].(map[string]any); ok {\n\t\t\t\t\t\t\tname, _ = tu[\"name\"].(string)\n\t\t\t\t\t\t\tcallID, _ = tu[\"id\"].(string)\n\t\t\t\t\t\t\tif a, ok := tu[\"arguments\"].(string); ok {\n\t\t\t\t\t\t\t\targs = a\n\t\t\t\t\t\t\t} else if inp, ok := tu[\"input\"]; ok {\n\t\t\t\t\t\t\t\tb, _ := json.Marshal(inp)\n\t\t\t\t\t\t\t\targs = string(b)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif args == \"\" {\n\t\t\t\t\t\targs = buildBuiltInToolCallArguments(itemType, elem)\n\t\t\t\t\t}\n\t\t\t\t\tif args == \"\" {\n\t\t\t\t\t\targs = \"{}\"\n\t\t\t\t\t}\n\t\t\t\t\tmessages = append(messages, Message{\n\t\t\t\t\t\tRole: \"assistant\",\n\t\t\t\t\t\tContent: \"\",\n\t\t\t\t\t\tToolCalls: []ToolCall{{\n\t\t\t\t\t\t\tID: callID,\n\t\t\t\t\t\t\tType: \"function\",\n\t\t\t\t\t\t\tFunction: FunctionCall{\n\t\t\t\t\t\t\t\tName: name,\n\t\t\t\t\t\t\t\tArguments: args,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}},\n\t\t\t\t\t})\n\t\t\t\t\tif callID != \"\" {\n\t\t\t\t\t\toutput := functionOutputs[callID]\n\t\t\t\t\t\tif output == \"\" {\n\t\t\t\t\t\t\toutput = \"[tool output missing]\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmessages = append(messages, Message{Role: \"tool\", ToolCallID: callID, Content: output})\n\t\t\t\t\t}\n\t\t\t\tcase \"function_call_output\", \"tool_result\", \"apply_patch_call_output\", \"shell_call_output\":\n\t\t\t\t\tcallID, _ := elem[\"call_id\"].(string)\n\t\t\t\t\tif callID == \"\" {\n\t\t\t\t\t\tcallID, _ = elem[\"tool_use_id\"].(string)\n\t\t\t\t\t}\n\t\t\t\t\tif callID != \"\" {\n\t\t\t\t\t\toutput := functionOutputs[callID]\n\t\t\t\t\t\tif output == \"\" {\n\t\t\t\t\t\t\tswitch o := elem[\"output\"].(type) {\n\t\t\t\t\t\t\tcase string:\n\t\t\t\t\t\t\t\toutput = o\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\tif o != nil {\n\t\t\t\t\t\t\t\t\tb, _ := json.Marshal(o)\n\t\t\t\t\t\t\t\t\toutput = string(b)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif output == \"\" {\n\t\t\t\t\t\t\tb, err := json.Marshal(elem)\n\t\t\t\t\t\t\tif err == nil {\n\t\t\t\t\t\t\t\toutput = string(b)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif output == \"\" {\n\t\t\t\t\t\t\toutput = \"[tool output missing]\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmessages = append(messages, Message{Role: \"tool\", ToolCallID: callID, Content: output})\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\tcase \"reasoning\":\n\t\t\t\t\tif text := extractTextFromContentParts(elem[\"summary\"]); text != \"\" {\n\t\t\t\t\t\tmessages = append(messages, Message{Role: \"assistant\", Content: \"\", ReasoningContent: &text})\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\tcase \"message\", \"\":\n\t\t\t\t\trole := \"user\"\n\t\t\t\t\tif r, ok := elem[\"role\"].(string); ok && r != \"\" {\n\t\t\t\t\t\trole = r\n\t\t\t\t\t}\n\t\t\t\t\tif role == \"developer\" {\n\t\t\t\t\t\trole = \"system\"\n\t\t\t\t\t}\n\t\t\t\t\tcontent := responsesContentToMessageContent(elem[\"content\"])\n\t\t\t\t\tmessages = append(messages, Message{Role: role, Content: content})\n\t\t\t\tdefault:\n\t\t\t\t\trole := \"user\"\n\t\t\t\t\tif r, ok := elem[\"role\"].(string); ok && r != \"\" {\n\t\t\t\t\t\trole = r\n\t\t\t\t\t}\n\t\t\t\t\tcontent := responsesContentToMessageContent(elem[\"content\"])\n\t\t\t\t\temptyContent := false\n\t\t\t\t\tswitch v := content.(type) {\n\t\t\t\t\tcase nil:\n\t\t\t\t\t\temptyContent = true\n\t\t\t\t\tcase string:\n\t\t\t\t\t\temptyContent = v == \"\"\n\t\t\t\t\tcase []any:\n\t\t\t\t\t\temptyContent = len(v) == 0\n\t\t\t\t\t}\n\t\t\t\t\tif emptyContent {\n\t\t\t\t\t\tb, err := json.Marshal(elem)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontent = string(b)\n\t\t\t\t\t}\n\t\t\t\t\tmessages = append(messages, Message{Role: role, Content: content})\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tb, _ := json.Marshal(elem)\n\t\t\t\tmessages = append(messages, Message{Role: \"user\", Content: string(b)})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tb, _ := json.Marshal(v)\n\t\tmessages = append(messages, Message{Role: \"user\", Content: string(b)})\n\t}\n\treturn messages\n}\n\nfunc convertResponsesTools(tools []ResponsesTool) []Tool {\n\tconverted := make([]Tool, 0, len(tools))\n\tfor _, tool := range tools {\n\t\tfn, ok := responsesToolFunction(tool)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tconverted = append(converted, Tool{Type: \"function\", Function: fn})\n\t}\n\treturn converted\n}\n\nfunc responsesToolFunction(tool ResponsesTool) (ToolFunction, bool) {\n\tswitch tool.Type {\n\tcase \"function\":\n\t\tfn := ToolFunction{\n\t\t\tName: tool.Name,\n\t\t\tDescription: tool.Description,\n\t\t\tParameters: tool.Parameters,\n\t\t}\n\t\tif tool.Function != nil {\n\t\t\tfn = *tool.Function\n\t\t}\n\t\tif fn.Parameters == nil {\n\t\t\tfn.Parameters = map[string]any{\"type\": \"object\", \"properties\": map[string]any{}}\n\t\t}\n\t\treturn fn, true\n\tcase \"apply_patch\":\n\t\treturn ToolFunction{\n\t\t\tName: \"apply_patch\",\n\t\t\tDescription: \"Create, update, or delete files using a structured patch operation or unified diff.\",\n\t\t\tParameters: map[string]any{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\"input\": map[string]any{\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Patch diff or patch instructions to apply.\",\n\t\t\t\t\t},\n\t\t\t\t\t\"operation\": map[string]any{\n\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\"description\": \"Structured patch operation, including file action and diff payload.\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}, true\n\tcase \"shell\":\n\t\treturn ToolFunction{\n\t\t\tName: \"shell\",\n\t\t\tDescription: \"Run a shell command in the local workspace and return stdout, stderr, and exit details.\",\n\t\t\tParameters: map[string]any{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\"command\": map[string]any{\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Shell command to execute.\",\n\t\t\t\t\t},\n\t\t\t\t\t\"timeout_ms\": map[string]any{\n\t\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\t\"description\": \"Optional timeout in milliseconds.\",\n\t\t\t\t\t},\n\t\t\t\t\t\"working_directory\": map[string]any{\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Optional working directory for the command.\",\n\t\t\t\t\t},\n\t\t\t\t\t\"max_output_tokens\": map[string]any{\n\t\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\t\"description\": \"Optional output budget hint.\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"required\": []string{\"command\"},\n\t\t\t},\n\t\t}, true\n\tdefault:\n\t\treturn ToolFunction{}, false\n\t}\n}\n\nfunc responsesToolName(tool ResponsesTool) string {\n\tswitch tool.Type {\n\tcase \"function\":\n\t\tif tool.Function != nil && tool.Function.Name != \"\" {\n\t\t\treturn tool.Function.Name\n\t\t}\n\t\treturn tool.Name\n\tcase \"apply_patch\":\n\t\treturn \"apply_patch\"\n\tcase \"shell\":\n\t\treturn \"shell\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc responsesToolKindMap(tools []ResponsesTool) map[string]string {\n\tkinds := make(map[string]string, len(tools))\n\tfor _, tool := range tools {\n\t\tname := responsesToolName(tool)\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkinds[name] = tool.Type\n\t}\n\treturn kinds\n}\n\nfunc toolCallOutputType(name string, kinds map[string]string) string {\n\tswitch kinds[name] {\n\tcase \"apply_patch\":\n\t\treturn \"apply_patch_call\"\n\tcase \"shell\":\n\t\treturn \"shell_call\"\n\tdefault:\n\t\treturn \"function_call\"\n\t}\n}\n\nfunc convertResponsesToolChoice(choice any) any {\n\tif choice == nil {\n\t\treturn nil\n\t}\n\tchoiceMap, ok := choice.(map[string]any)\n\tif !ok {\n\t\treturn choice\n\t}\n\tif choiceMap[\"type\"] == \"function\" {\n\t\tif name, ok := choiceMap[\"name\"].(string); ok && name != \"\" {\n\t\t\treturn map[string]any{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": map[string]any{\"name\": name},\n\t\t\t}\n\t\t}\n\t}\n\tif choiceType, ok := choiceMap[\"type\"].(string); ok {\n\t\tswitch choiceType {\n\t\tcase \"apply_patch\", \"shell\":\n\t\t\treturn map[string]any{\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": map[string]any{\"name\": choiceType},\n\t\t\t}\n\t\t}\n\t}\n\treturn choice\n}\n\nfunc collectFunctionOutputs(items []any) map[string]string {\n\toutputs := map[string]string{}\n\tfor _, item := range items {\n\t\telem, ok := item.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\titemType, _ := elem[\"type\"].(string)\n\t\tswitch itemType {\n\t\tcase \"function_call_output\", \"apply_patch_call_output\", \"shell_call_output\":\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\t\tcallID, _ := elem[\"call_id\"].(string)\n\t\tif callID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch v := elem[\"output\"].(type) {\n\t\tcase string:\n\t\t\toutputs[callID] = v\n\t\tdefault:\n\t\t\tb, _ := json.Marshal(v)\n\t\t\toutputs[callID] = string(b)\n\t\t}\n\t}\n\treturn outputs\n}\n\nfunc parseJSONString(input string) any {\n\tvar parsed any\n\tif input == \"\" {\n\t\treturn nil\n\t}\n\tif err := json.Unmarshal([]byte(input), &parsed); err != nil {\n\t\treturn nil\n\t}\n\treturn parsed\n}\n\nfunc buildBuiltInToolCallArguments(itemType string, elem map[string]any) string {\n\tif arguments, ok := elem[\"arguments\"].(string); ok && arguments != \"\" {\n\t\treturn arguments\n\t}\n\n\tpayload := map[string]any{}\n\tswitch itemType {\n\tcase \"apply_patch_call\":\n\t\tif input, ok := elem[\"input\"].(string); ok && input != \"\" {\n\t\t\tpayload[\"input\"] = input\n\t\t}\n\t\tif operation, ok := elem[\"operation\"]; ok && operation != nil {\n\t\t\tpayload[\"operation\"] = operation\n\t\t}\n\tcase \"shell_call\":\n\t\tfor _, key := range []string{\"command\", \"timeout_ms\", \"working_directory\", \"max_output_tokens\"} {\n\t\t\tif value, ok := elem[key]; ok && value != nil {\n\t\t\t\tpayload[key] = value\n\t\t\t}\n\t\t}\n\t}\n\tif len(payload) == 0 {\n\t\tpayload = elem\n\t}\n\tencoded, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn \"{}\"\n\t}\n\treturn string(encoded)\n}\n\nfunc buildResponseToolCallItem(tc ToolCall, outputType string) map[string]any {\n\tswitch outputType {\n\tcase \"apply_patch_call\":\n\t\titem := map[string]any{\n\t\t\t\"id\": \"apc_\" + tc.ID,\n\t\t\t\"type\": outputType,\n\t\t\t\"status\": \"completed\",\n\t\t\t\"call_id\": tc.ID,\n\t\t}\n\t\tif parsed, ok := parseJSONString(tc.Function.Arguments).(map[string]any); ok {\n\t\t\tfor key, value := range parsed {\n\t\t\t\titem[key] = value\n\t\t\t}\n\t\t} else if tc.Function.Arguments != \"\" {\n\t\t\titem[\"arguments\"] = tc.Function.Arguments\n\t\t}\n\t\treturn item\n\tcase \"shell_call\":\n\t\titem := map[string]any{\n\t\t\t\"id\": \"shc_\" + tc.ID,\n\t\t\t\"type\": outputType,\n\t\t\t\"status\": \"completed\",\n\t\t\t\"call_id\": tc.ID,\n\t\t}\n\t\tif parsed, ok := parseJSONString(tc.Function.Arguments).(map[string]any); ok {\n\t\t\tfor key, value := range parsed {\n\t\t\t\titem[key] = value\n\t\t\t}\n\t\t} else if tc.Function.Arguments != \"\" {\n\t\t\titem[\"arguments\"] = tc.Function.Arguments\n\t\t}\n\t\treturn item\n\tdefault:\n\t\treturn map[string]any{\n\t\t\t\"id\": \"fc_\" + tc.ID,\n\t\t\t\"type\": \"function_call\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"arguments\": tc.Function.Arguments,\n\t\t\t\"call_id\": tc.ID,\n\t\t\t\"name\": tc.Function.Name,\n\t\t}\n\t}\n}\n\nfunc cloneJSONValue[T any](value T) T {\n\tencoded, err := json.Marshal(value)\n\tif err != nil {\n\t\treturn value\n\t}\n\tvar cloned T\n\tif err := json.Unmarshal(encoded, &cloned); err != nil {\n\t\treturn value\n\t}\n\treturn cloned\n}\n\nfunc storeResponseState(response map[string]any, req ResponsesAPIRequest) {\n\tif req.Store != nil && !*req.Store {\n\t\treturn\n\t}\n\tresponseID, _ := response[\"id\"].(string)\n\tif responseID == \"\" {\n\t\treturn\n\t}\n\toutput, _ := response[\"output\"].([]any)\n\tstoredResponsesMu.Lock()\n\tstoredResponses[responseID] = StoredResponseState{\n\t\tModel: req.Model,\n\t\tInstructions: req.Instructions,\n\t\tTools: cloneJSONValue(req.Tools),\n\t\tToolChoice: cloneJSONValue(req.ToolChoice),\n\t\tOutput: cloneJSONValue(output),\n\t}\n\tstoredResponsesMu.Unlock()\n}\n\nfunc loadResponseState(responseID string) (StoredResponseState, bool) {\n\tstoredResponsesMu.RLock()\n\tdefer storedResponsesMu.RUnlock()\n\tstate, ok := storedResponses[responseID]\n\tif !ok {\n\t\treturn StoredResponseState{}, false\n\t}\n\treturn cloneJSONValue(state), true\n}\n\nfunc extractTextFromContentParts(content any) string {\n\tparts, ok := content.([]any)\n\tif !ok {\n\t\tif s, ok := content.(string); ok {\n\t\t\treturn s\n\t\t}\n\t\treturn \"\"\n\t}\n\tvar texts []string\n\tfor _, p := range parts {\n\t\tif part, ok := p.(map[string]any); ok {\n\t\t\tif part[\"type\"] == \"input_text\" || part[\"type\"] == \"output_text\" {\n\t\t\t\tif t, ok := part[\"text\"].(string); ok {\n\t\t\t\t\ttexts = append(texts, t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.Join(texts, \"\\n\")\n}\n\nfunc convertResponsesContentPart(part map[string]any) (map[string]any, bool) {\n\tpartType, _ := part[\"type\"].(string)\n\tswitch partType {\n\tcase \"input_text\", \"output_text\", \"text\":\n\t\ttext, _ := part[\"text\"].(string)\n\t\tif text == \"\" {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn map[string]any{\n\t\t\t\"type\": \"text\",\n\t\t\t\"text\": text,\n\t\t}, true\n\tcase \"input_image\":\n\t\timageURL, _ := part[\"image_url\"].(string)\n\t\tif imageURL == \"\" {\n\t\t\treturn nil, false\n\t\t}\n\t\timageURLValue := map[string]any{\n\t\t\t\"url\": imageURL,\n\t\t}\n\t\tif detail, ok := part[\"detail\"].(string); ok && detail != \"\" {\n\t\t\timageURLValue[\"detail\"] = detail\n\t\t}\n\t\treturn map[string]any{\n\t\t\t\"type\": \"image_url\",\n\t\t\t\"image_url\": imageURLValue,\n\t\t}, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc responsesContentToMessageContent(content any) any {\n\tif content == nil {\n\t\treturn nil\n\t}\n\tif s, ok := content.(string); ok {\n\t\treturn s\n\t}\n\n\tparts, ok := content.([]any)\n\tif !ok {\n\t\tb, err := json.Marshal(content)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn string(b)\n\t}\n\n\tconvertedParts := make([]any, 0, len(parts))\n\ttexts := make([]string, 0, len(parts))\n\tonlyTextParts := true\n\n\tfor _, rawPart := range parts {\n\t\tpart, ok := rawPart.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tconvertedPart, ok := convertResponsesContentPart(part)\n\t\tif !ok {\n\t\t\ttext := extractTextFromContentParts([]any{part})\n\t\t\tif text == \"\" {\n\t\t\t\tb, err := json.Marshal(part)\n\t\t\t\tif err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttext = string(b)\n\t\t\t}\n\t\t\tconvertedParts = append(convertedParts, map[string]any{\n\t\t\t\t\"type\": \"text\",\n\t\t\t\t\"text\": text,\n\t\t\t})\n\t\t\ttexts = append(texts, text)\n\t\t\tcontinue\n\t\t}\n\n\t\tif convertedPart[\"type\"] != \"text\" {\n\t\t\tonlyTextParts = false\n\t\t}\n\t\tif text, ok := convertedPart[\"text\"].(string); ok && text != \"\" {\n\t\t\ttexts = append(texts, text)\n\t\t}\n\t\tconvertedParts = append(convertedParts, convertedPart)\n\t}\n\n\tif len(convertedParts) == 0 {\n\t\treturn \"\"\n\t}\n\tif onlyTextParts {\n\t\treturn strings.Join(texts, \"\\n\")\n\t}\n\treturn convertedParts\n}\n\nfunc chatContentToResponsesContent(content any) ([]any, string) {\n\tswitch v := content.(type) {\n\tcase nil:\n\t\treturn nil, \"\"\n\tcase string:\n\t\tif v == \"\" {\n\t\t\treturn nil, \"\"\n\t\t}\n\t\treturn []any{map[string]any{\n\t\t\t\"type\": \"output_text\",\n\t\t\t\"text\": v,\n\t\t\t\"annotations\": []any{},\n\t\t\t\"logprobs\": []any{},\n\t\t}}, v\n\tcase []any:\n\t\tparts := make([]any, 0, len(v))\n\t\ttexts := make([]string, 0, len(v))\n\t\tfor _, rawPart := range v {\n\t\t\tpart, ok := rawPart.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpartType, _ := part[\"type\"].(string)\n\t\t\tswitch partType {\n\t\t\tcase \"text\", \"input_text\", \"output_text\":\n\t\t\t\ttext, _ := part[\"text\"].(string)\n\t\t\t\tif text == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tannotations, ok := part[\"annotations\"]\n\t\t\t\tif !ok {\n\t\t\t\t\tannotations = []any{}\n\t\t\t\t}\n\t\t\t\tlogprobs, ok := part[\"logprobs\"]\n\t\t\t\tif !ok {\n\t\t\t\t\tlogprobs = []any{}\n\t\t\t\t}\n\t\t\t\ttexts = append(texts, text)\n\t\t\t\tparts = append(parts, map[string]any{\n\t\t\t\t\t\"type\": \"output_text\",\n\t\t\t\t\t\"text\": text,\n\t\t\t\t\t\"annotations\": annotations,\n\t\t\t\t\t\"logprobs\": logprobs,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\treturn parts, strings.Join(texts, \"\\n\")\n\tdefault:\n\t\tb, err := json.Marshal(v)\n\t\tif err != nil {\n\t\t\treturn nil, \"\"\n\t\t}\n\t\ttext := string(b)\n\t\treturn []any{map[string]any{\n\t\t\t\"type\": \"output_text\",\n\t\t\t\"text\": text,\n\t\t\t\"annotations\": []any{},\n\t\t\t\"logprobs\": []any{},\n\t\t}}, text\n\t}\n}\n\nfunc responsesHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\tauth := extractUpstreamAuth(r)\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcnt := requestCount.Add(1)\n\tmaybeLogBodySummary(r.Context(), \"responses request body\", body)\n\t_ = cnt\n\n\tvar respReq ResponsesAPIRequest\n\tif err := json.Unmarshal(body, &respReq); err != nil {\n\t\thttp.Error(w, \"Invalid JSON\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tmodelIn := respReq.Model\n\trespReq.Model = resolveModel(respReq.Model)\n\tpreviousState, hasPreviousState := StoredResponseState{}, false\n\tif respReq.PreviousResponseID != \"\" {\n\t\tpreviousState, hasPreviousState = loadResponseState(respReq.PreviousResponseID)\n\t\tif respReq.Model == \"\" && previousState.Model != \"\" {\n\t\t\trespReq.Model = previousState.Model\n\t\t}\n\t\tif len(respReq.Tools) == 0 && len(previousState.Tools) > 0 {\n\t\t\trespReq.Tools = previousState.Tools\n\t\t}\n\t\tif respReq.ToolChoice == nil && previousState.ToolChoice != nil {\n\t\t\trespReq.ToolChoice = previousState.ToolChoice\n\t\t}\n\t}\n\tif respReq.Model == \"\" {\n\t\tmodelIDs := getModelIDs()\n\t\tif len(modelIDs) > 0 {\n\t\t\trespReq.Model = modelIDs[0]\n\t\t} else {\n\t\t\trespReq.Model = \"deepseek-v4-flash-free\"\n\t\t}\n\t}\n\n\t// 多模态路由\n\n\tmessages := respReq.Messages\n\tif len(messages) == 0 {\n\t\tif hasPreviousState && len(previousState.Output) > 0 {\n\t\t\tmessages = append(messages, responsesInputToMessages(previousState.Output, \"\")...)\n\t\t}\n\t\tmessages = append(messages, responsesInputToMessages(respReq.Input, respReq.Instructions)...)\n\t} else if respReq.Instructions != \"\" {\n\t\tmessages = append([]Message{{Role: \"system\", Content: respReq.Instructions}}, messages...)\n\t}\n\n\tchatReq := OpenAIRequest{\n\t\tModel: respReq.Model,\n\t\tMessages: messages,\n\t\tStream: respReq.Stream,\n\t}\n\tif respReq.Stream {\n\t\tchatReq.ExtraBody = map[string]any{\n\t\t\t\"stream_options\": map[string]any{\"include_usage\": true},\n\t\t}\n\t}\n\tif respReq.Temperature != nil {\n\t\tchatReq.Temperature = respReq.Temperature\n\t}\n\tif respReq.MaxTokens != nil {\n\t\tchatReq.MaxTokens = respReq.MaxTokens\n\t}\n\tif respReq.TopP != nil {\n\t\tchatReq.TopP = respReq.TopP\n\t}\n\tif len(respReq.Tools) > 0 {\n\t\tchatReq.Tools = convertResponsesTools(respReq.Tools)\n\t}\n\tif respReq.ToolChoice != nil {\n\t\tchatReq.ToolChoice = convertResponsesToolChoice(respReq.ToolChoice)\n\t}\n\tif respReq.ParallelToolCalls != nil {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"parallel_tool_calls\"] = *respReq.ParallelToolCalls\n\t}\n\tif respReq.Stop != nil {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"stop\"] = respReq.Stop\n\t}\n\tif respReq.FrequencyPenalty != nil {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"frequency_penalty\"] = *respReq.FrequencyPenalty\n\t}\n\tif respReq.PresencePenalty != nil {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"presence_penalty\"] = *respReq.PresencePenalty\n\t}\n\tif respReq.User != \"\" {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tchatReq.ExtraBody[\"user\"] = respReq.User\n\t}\n\tif respReq.StreamOptions != nil {\n\t\tif chatReq.ExtraBody == nil {\n\t\t\tchatReq.ExtraBody = map[string]any{}\n\t\t}\n\t\tstreamOptions, ok := respReq.StreamOptions.(map[string]any)\n\t\tif !ok {\n\t\t\tstreamOptions = map[string]any{}\n\t\t}\n\t\tif _, exists := streamOptions[\"include_usage\"]; !exists && respReq.Stream {\n\t\t\tstreamOptions[\"include_usage\"] = true\n\t\t}\n\t\tchatReq.ExtraBody[\"stream_options\"] = streamOptions\n\t}\n\t// 将 Responses API reasoning.effort 映射到 Chat Completions\n\tif !getForceDisableThinking() && respReq.Reasoning.Effort != \"\" {\n\t\tif respReq.Reasoning.Effort != \"none\" {\n\t\t\tchatReq.ReasoningEffort = respReq.Reasoning.Effort\n\t\t}\n\t}\n\n\twantReasoning := !getForceDisableThinking()\n\tchatReq.Messages = fixToolCallGaps(chatReq.Messages)\n\tkeepReasoning := wantsReasoning(&chatReq)\n\tchatReq.Messages = ensureReasoningContent(chatReq.Messages, keepReasoning)\n\n\teffortIn := chatReq.ReasoningEffort\n\tif effortIn == \"\" {\n\t\teffortIn = respReq.Reasoning.Effort\n\t}\n\tupstreamSurface := \"zen\"\n\tif auth.shouldUseGoEndpoint(chatReq.Model) {\n\t\tupstreamSurface = \"go\"\n\t}\n\tlogRequestPlan(r.Context(), map[string]any{\n\t\t\"protocol\": \"responses\",\n\t\t\"model_in\": modelIn,\n\t\t\"model_resolved\": chatReq.Model,\n\t\t\"auth_mode\": authModeString(auth.Mode),\n\t\t\"auth_source\": auth.Source,\n\t\t\"has_key\": auth.Token != \"\",\n\t\t\"upstream_surface\": upstreamSurface,\n\t\t\"stream\": respReq.Stream,\n\t\t\"keep_reasoning\": keepReasoning,\n\t\t\"thinking\": thinkingState(nil),\n\t\t\"reasoning_effort_in\": effortIn,\n\t\t\"reasoning_effort_out\": mappedReasoningEffort(effortIn),\n\t\t\"tools_count\": len(respReq.Tools),\n\t\t\"messages_count\": len(chatReq.Messages),\n\t})\n\n\tupstreamBody := buildUpstreamBody(&chatReq)\n\n\tif respReq.Stream {\n\t\tupResp, status, _, err := callOpenCodeAPIStream(r.Context(), upstreamBody, chatReq.Model, auth)\n\t\tif err != nil || status < 200 || status >= 300 {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(status)\n\t\t\tif upResp != nil {\n\t\t\t\terrBody, _ := io.ReadAll(upResp)\n\t\t\t\tif len(errBody) > 0 {\n\t\t\t\t\tw.Write(errBody)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\"error\": map[string]any{\"message\": \"upstream error\"}})\n\t\t\treturn\n\t\t}\n\t\tdefer upResp.Close()\n\n\t\tresp := &http.Response{\n\t\t\tStatusCode: status,\n\t\t\tBody: upResp,\n\t\t\tHeader: make(http.Header),\n\t\t}\n\t\tresponsesStreamHandler(w, r, resp, chatReq.Model, chatReq.Model, wantReasoning, respReq.Tools, respReq.ToolChoice, respReq)\n\t\treturn\n\t}\n\n\trespBody, status, _, err := callOpenCodeAPI(r.Context(), upstreamBody, chatReq.Model, auth)\n\tif err != nil || status < 200 || status >= 300 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(status)\n\t\tif len(respBody) > 0 {\n\t\t\tw.Write(respBody)\n\t\t} else {\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\"error\": map[string]any{\"message\": \"upstream error\"}})\n\t\t}\n\t\treturn\n\t}\n\n\tresponsesBody := convertChatToResponses(respBody, chatReq.Model, wantReasoning, respReq.Tools, respReq.ToolChoice)\n\tvar responseMap map[string]any\n\tif json.Unmarshal(responsesBody, &responseMap) == nil {\n\t\tapplyResponsesRequestEcho(responseMap, respReq)\n\t\tif enriched, marshalErr := json.Marshal(responseMap); marshalErr == nil {\n\t\t\tresponsesBody = enriched\n\t\t}\n\t\tstoreResponseState(responseMap, respReq)\n\t}\n\n\tresult := summarizeChatResult(respBody)\n\tlogRequestResult(r.Context(), result)\n\n\tvar usageResp map[string]any\n\tif json.Unmarshal(respBody, &usageResp) == nil {\n\t\tif u, ok := usageResp[\"usage\"].(map[string]any); ok {\n\t\t\tpt, _ := u[\"prompt_tokens\"].(float64)\n\t\t\tct, _ := u[\"completion_tokens\"].(float64)\n\t\t\ttt, _ := u[\"total_tokens\"].(float64)\n\t\t\tif tt > 0 {\n\t\t\t\trecordTokenUsage(chatReq.Model, int64(pt), int64(ct), int64(tt))\n\t\t\t}\n\t\t}\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\tmaybeLogBodySummary(r.Context(), \"responses response body\", responsesBody)\n\tw.Write(responsesBody)\n}\n\n// ======================== Responses Stream Handler ========================\n\nfunc responsesStreamHandler(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, _ string, wantReasoning bool, tools []ResponsesTool, toolChoice any, originalReq ResponsesAPIRequest) {\n\tctx := context.Background()\n\tif r != nil {\n\t\tctx = r.Context()\n\t}\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tflusher, _ := w.(http.Flusher)\n\treader := bufio.NewReader(resp.Body)\n\tstats := &streamResultStats{start: time.Now()}\n\n\tresponseID := \"resp_\" + time.Now().Format(\"20060102150405\") + \"_\" + randomString(8)\n\treasoningID := \"rs_\" + responseID\n\tmsgID := \"msg_\" + responseID + \"_0\"\n\tcreatedAt := time.Now().Unix()\n\tseq := 0\n\n\treasoningStarted := false\n\treasoningDone := false\n\tmessageStarted := false\n\tmessageDone := false\n\tfullReasoning := \"\"\n\tfullText := \"\"\n\ttotalUsage := map[string]any{}\n\tcreatedSent := false\n\tterminalStatus := \"completed\"\n\tterminalEvent := \"response.completed\"\n\titemStatus := \"completed\"\n\ttoolCalls := map[int]map[string]any{}\n\ttoolOrder := []int{}\n\ttoolKinds := responsesToolKindMap(tools)\n\tindexAllocator := outputIndexAllocator{}\n\treasoningOutputIndex := -1\n\tmessageIndex := -1\n\n\tdefer func() {\n\t\tstats.textChars = len(fullText)\n\t\tstats.reasoningChars = len(fullReasoning)\n\t\tstats.toolCallCount = len(toolOrder)\n\t\tstats.log(ctx, \"responses\")\n\t}()\n\n\tmessageOutputIndex := func() int {\n\t\tif messageIndex < 0 {\n\t\t\tmessageIndex = indexAllocator.Allocate()\n\t\t}\n\t\treturn messageIndex\n\t}\n\n\treasoningItem := func(status string) map[string]any {\n\t\titem := map[string]any{\n\t\t\t\"id\": reasoningID,\n\t\t\t\"type\": \"reasoning\",\n\t\t\t\"summary\": []any{},\n\t\t}\n\t\tif status != \"\" {\n\t\t\titem[\"status\"] = status\n\t\t}\n\t\tif status == \"completed\" {\n\t\t\titem[\"encrypted_content\"] = \"\"\n\t\t}\n\t\tif fullReasoning != \"\" {\n\t\t\titem[\"summary\"] = []any{map[string]any{\"type\": \"summary_text\", \"text\": fullReasoning}}\n\t\t}\n\t\treturn item\n\t}\n\n\tmessageItem := func(status string) map[string]any {\n\t\tcontent := []any{map[string]any{\n\t\t\t\"type\": \"output_text\",\n\t\t\t\"annotations\": []any{},\n\t\t\t\"logprobs\": []any{},\n\t\t\t\"text\": fullText,\n\t\t}}\n\t\treturn map[string]any{\n\t\t\t\"id\": msgID,\n\t\t\t\"type\": \"message\",\n\t\t\t\"status\": status,\n\t\t\t\"content\": content,\n\t\t\t\"role\": \"assistant\",\n\t\t}\n\t}\n\n\temitReasoningDone := func() {\n\t\tif !reasoningStarted || reasoningDone {\n\t\t\treturn\n\t\t}\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.reasoning_summary_text.done\", map[string]any{\n\t\t\t\"type\": \"response.reasoning_summary_text.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"item_id\": reasoningID,\n\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\"summary_index\": 0,\n\t\t\t\"text\": fullReasoning,\n\t\t})\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.reasoning_summary_part.done\", map[string]any{\n\t\t\t\"type\": \"response.reasoning_summary_part.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"item_id\": reasoningID,\n\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\"summary_index\": 0,\n\t\t\t\"part\": map[string]any{\"type\": \"summary_text\", \"text\": fullReasoning},\n\t\t})\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.output_item.done\", map[string]any{\n\t\t\t\"type\": \"response.output_item.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\"item\": reasoningItem(itemStatus),\n\t\t})\n\t\treasoningDone = true\n\t}\n\n\temitMessageDone := func() {\n\t\tif !messageStarted || messageDone {\n\t\t\treturn\n\t\t}\n\t\tidx := messageOutputIndex()\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.output_text.done\", map[string]any{\n\t\t\t\"type\": \"response.output_text.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"item_id\": msgID,\n\t\t\t\"output_index\": idx,\n\t\t\t\"content_index\": 0,\n\t\t\t\"text\": fullText,\n\t\t\t\"logprobs\": []any{},\n\t\t})\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.content_part.done\", map[string]any{\n\t\t\t\"type\": \"response.content_part.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"item_id\": msgID,\n\t\t\t\"output_index\": idx,\n\t\t\t\"content_index\": 0,\n\t\t\t\"part\": map[string]any{\"type\": \"output_text\", \"annotations\": []any{}, \"logprobs\": []any{}, \"text\": fullText},\n\t\t})\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.output_item.done\", map[string]any{\n\t\t\t\"type\": \"response.output_item.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"output_index\": idx,\n\t\t\t\"item\": messageItem(itemStatus),\n\t\t})\n\t\tmessageDone = true\n\t}\n\n\temitToolCallDone := func(idx int, call map[string]any) {\n\t\tif done, _ := call[\"done\"].(bool); done {\n\t\t\treturn\n\t\t}\n\t\tcall[\"done\"] = true\n\t\titemID, _ := call[\"item_id\"].(string)\n\t\tcallID, _ := call[\"call_id\"].(string)\n\t\tname, _ := call[\"name\"].(string)\n\t\targs, _ := call[\"arguments\"].(string)\n\t\tseq++\n\t\temitSSEEvent(w, flusher, \"response.function_call_arguments.done\", map[string]any{\n\t\t\t\"type\": \"response.function_call_arguments.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"item_id\": itemID,\n\t\t\t\"output_index\": idx,\n\t\t\t\"name\": name,\n\t\t\t\"arguments\": args,\n\t\t})\n\t\tseq++\n\t\titemType, _ := call[\"item_type\"].(string)\n\t\tif itemType == \"\" {\n\t\t\titemType = \"function_call\"\n\t\t}\n\t\titem := buildResponseToolCallItem(ToolCall{ID: callID, Function: FunctionCall{Name: name, Arguments: args}}, itemType)\n\t\titem[\"status\"] = itemStatus\n\t\temitSSEEvent(w, flusher, \"response.output_item.done\", map[string]any{\n\t\t\t\"type\": \"response.output_item.done\",\n\t\t\t\"sequence_number\": seq,\n\t\t\t\"output_index\": idx,\n\t\t\t\"item\": item,\n\t\t})\n\t}\n\n\tfor {\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treqLogger(ctx).Error(\"stream read error\", \"error\", err)\n\t\t\treturn\n\t\t}\n\n\t\ttrimmed := strings.TrimSpace(line)\n\t\tif trimmed == \"data: [DONE]\" || trimmed == \"[DONE]\" {\n\t\t\tstats.doneSeen = true\n\t\t\tbreak\n\t\t}\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar chunk map[string]any\n\t\tif err := json.Unmarshal([]byte(line[6:]), &chunk); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tstats.noteChunk()\n\t\tif !createdSent {\n\t\t\tif id, ok := chunk[\"id\"].(string); ok && id != \"\" {\n\t\t\t\tresponseID = id\n\t\t\t\treasoningID = \"rs_\" + responseID + \"_0\"\n\t\t\t\tmsgID = \"msg_\" + responseID + \"_0\"\n\t\t\t}\n\t\t\tif created, ok := chunk[\"created\"].(float64); ok {\n\t\t\t\tcreatedAt = int64(created)\n\t\t\t}\n\t\t\tseq++\n\t\t\temitSSEEvent(w, flusher, \"response.created\", map[string]any{\n\t\t\t\t\"type\": \"response.created\",\n\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\"response\": map[string]any{\"id\": responseID, \"object\": \"response\", \"created_at\": createdAt, \"status\": \"in_progress\", \"background\": false, \"error\": nil, \"output\": []any{}},\n\t\t\t})\n\t\t\tseq++\n\t\t\temitSSEEvent(w, flusher, \"response.in_progress\", map[string]any{\n\t\t\t\t\"type\": \"response.in_progress\",\n\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\"response\": map[string]any{\"id\": responseID, \"object\": \"response\", \"created_at\": createdAt, \"status\": \"in_progress\"},\n\t\t\t})\n\t\t\tcreatedSent = true\n\t\t}\n\t\tchoices, ok := chunk[\"choices\"].([]any)\n\t\tif !ok || len(choices) == 0 {\n\t\t\tif usage, ok := chunk[\"usage\"].(map[string]any); ok {\n\t\t\t\ttotalUsage = usage\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tchoice, _ := choices[0].(map[string]any)\n\t\tdelta, _ := choice[\"delta\"].(map[string]any)\n\t\tfinishReason, _ := choice[\"finish_reason\"].(string)\n\t\tif finishReason != \"\" {\n\t\t\tstats.finishReason = finishReason\n\t\t\tstats.sawFinish = true\n\t\t}\n\n\t\tif rc, ok := delta[\"reasoning_content\"]; ok && wantReasoning {\n\t\t\trcStr, _ := rc.(string)\n\t\t\tif rcStr != \"\" {\n\t\t\t\tif !reasoningStarted {\n\t\t\t\t\treasoningOutputIndex = indexAllocator.Allocate()\n\t\t\t\t\tseq++\n\t\t\t\t\temitSSEEvent(w, flusher, \"response.output_item.added\", map[string]any{\n\t\t\t\t\t\t\"type\": \"response.output_item.added\",\n\t\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\t\t\t\"item\": reasoningItem(\"in_progress\"),\n\t\t\t\t\t})\n\t\t\t\t\tseq++\n\t\t\t\t\temitSSEEvent(w, flusher, \"response.reasoning_summary_part.added\", map[string]any{\n\t\t\t\t\t\t\"type\": \"response.reasoning_summary_part.added\",\n\t\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\t\"item_id\": reasoningID,\n\t\t\t\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\t\t\t\"summary_index\": 0,\n\t\t\t\t\t\t\"part\": map[string]any{\"type\": \"summary_text\", \"text\": \"\"},\n\t\t\t\t\t})\n\t\t\t\t\treasoningStarted = true\n\t\t\t\t}\n\t\t\t\tfullReasoning += rcStr\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.reasoning_summary_text.delta\", map[string]any{\n\t\t\t\t\t\"type\": \"response.reasoning_summary_text.delta\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"item_id\": reasoningID,\n\t\t\t\t\t\"output_index\": reasoningOutputIndex,\n\t\t\t\t\t\"summary_index\": 0,\n\t\t\t\t\t\"delta\": rcStr,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tcontentStr := \"\"\n\t\tif c, ok := delta[\"content\"]; ok && c != nil {\n\t\t\tcontentStr, _ = c.(string)\n\t\t}\n\t\t// #37635: when thinking is not kept, promote misplaced reasoning to visible text.\n\t\tif contentStr == \"\" && !wantReasoning {\n\t\t\tif rc, ok := delta[\"reasoning_content\"].(string); ok {\n\t\t\t\tif rc != \"\" {\n\t\t\t\t\tstats.promotedReasoning = true\n\t\t\t\t}\n\t\t\t\tcontentStr = rc\n\t\t\t}\n\t\t}\n\t\tif contentStr != \"\" {\n\t\t\t// The terminal finish reason determines the item's final status. Keep the\n\t\t\t// reasoning item open until that reason is known so a truncation cannot\n\t\t\t// first announce it as completed.\n\t\t\tif !messageStarted {\n\t\t\t\tidx := messageOutputIndex()\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.output_item.added\", map[string]any{\n\t\t\t\t\t\"type\": \"response.output_item.added\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"output_index\": idx,\n\t\t\t\t\t\"item\": map[string]any{\"id\": msgID, \"type\": \"message\", \"status\": \"in_progress\", \"content\": []any{}, \"role\": \"assistant\"},\n\t\t\t\t})\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.content_part.added\", map[string]any{\n\t\t\t\t\t\"type\": \"response.content_part.added\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"item_id\": msgID,\n\t\t\t\t\t\"output_index\": idx,\n\t\t\t\t\t\"content_index\": 0,\n\t\t\t\t\t\"part\": map[string]any{\"type\": \"output_text\", \"annotations\": []any{}, \"logprobs\": []any{}, \"text\": \"\"},\n\t\t\t\t})\n\t\t\t\tmessageStarted = true\n\t\t\t}\n\t\t\tfullText += contentStr\n\t\t\tseq++\n\t\t\temitSSEEvent(w, flusher, \"response.output_text.delta\", map[string]any{\n\t\t\t\t\"type\": \"response.output_text.delta\",\n\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\"item_id\": msgID,\n\t\t\t\t\"output_index\": messageOutputIndex(),\n\t\t\t\t\"content_index\": 0,\n\t\t\t\t\"delta\": contentStr,\n\t\t\t\t\"logprobs\": []any{},\n\t\t\t})\n\t\t}\n\n\t\trawToolCalls, _ := delta[\"tool_calls\"].([]any)\n\t\tfor _, rawToolCall := range rawToolCalls {\n\t\t\ttc, ok := rawToolCall.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidxFloat, _ := tc[\"index\"].(float64)\n\t\t\tupstreamIndex := int(idxFloat)\n\t\t\tcall, exists := toolCalls[upstreamIndex]\n\t\t\tif !exists {\n\t\t\t\toutputIndex := indexAllocator.Allocate()\n\t\t\t\tcallID, _ := tc[\"id\"].(string)\n\t\t\t\tif callID == \"\" {\n\t\t\t\t\tcallID = \"call_\" + randomString(12)\n\t\t\t\t}\n\t\t\t\tfn, _ := tc[\"function\"].(map[string]any)\n\t\t\t\tname, _ := fn[\"name\"].(string)\n\t\t\t\titemType := toolCallOutputType(name, toolKinds)\n\t\t\t\tcall = map[string]any{\n\t\t\t\t\t\"output_index\": outputIndex,\n\t\t\t\t\t\"item_id\": \"fc_\" + callID,\n\t\t\t\t\t\"call_id\": callID,\n\t\t\t\t\t\"name\": name,\n\t\t\t\t\t\"arguments\": \"\",\n\t\t\t\t\t\"done\": false,\n\t\t\t\t\t\"item_type\": itemType,\n\t\t\t\t}\n\t\t\t\ttoolCalls[upstreamIndex] = call\n\t\t\t\ttoolOrder = append(toolOrder, upstreamIndex)\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.output_item.added\", map[string]any{\n\t\t\t\t\t\"type\": \"response.output_item.added\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"output_index\": outputIndex,\n\t\t\t\t\t\"item\": map[string]any{\n\t\t\t\t\t\t\"id\": call[\"item_id\"],\n\t\t\t\t\t\t\"type\": itemType,\n\t\t\t\t\t\t\"status\": \"in_progress\",\n\t\t\t\t\t\t\"arguments\": \"\",\n\t\t\t\t\t\t\"call_id\": callID,\n\t\t\t\t\t\t\"name\": name,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t\tfn, _ := tc[\"function\"].(map[string]any)\n\t\t\tif name, _ := fn[\"name\"].(string); name != \"\" {\n\t\t\t\tcall[\"name\"] = name\n\t\t\t\tif call[\"item_type\"] == \"function_call\" {\n\t\t\t\t\tcall[\"item_type\"] = toolCallOutputType(name, toolKinds)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif argDelta, _ := fn[\"arguments\"].(string); argDelta != \"\" {\n\t\t\t\tcall[\"arguments\"] = call[\"arguments\"].(string) + argDelta\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.function_call_arguments.delta\", map[string]any{\n\t\t\t\t\t\"type\": \"response.function_call_arguments.delta\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"item_id\": call[\"item_id\"],\n\t\t\t\t\t\"output_index\": call[\"output_index\"],\n\t\t\t\t\t\"delta\": argDelta,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tif usage, ok := chunk[\"usage\"].(map[string]any); ok {\n\t\t\ttotalUsage = usage\n\t\t}\n\t\tif finishReason == \"stop\" || finishReason == \"length\" || finishReason == \"content_filter\" {\n\t\t\tif finishReason == \"length\" {\n\t\t\t\tterminalStatus = \"incomplete\"\n\t\t\t\tterminalEvent = \"response.incomplete\"\n\t\t\t\titemStatus = \"incomplete\"\n\t\t\t}\n\t\t\temitReasoningDone()\n\t\t\tif !messageStarted && len(toolCalls) == 0 {\n\t\t\t\tidx := messageOutputIndex()\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.output_item.added\", map[string]any{\n\t\t\t\t\t\"type\": \"response.output_item.added\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"output_index\": idx,\n\t\t\t\t\t\"item\": map[string]any{\"id\": msgID, \"type\": \"message\", \"status\": \"in_progress\", \"content\": []any{}, \"role\": \"assistant\"},\n\t\t\t\t})\n\t\t\t\tseq++\n\t\t\t\temitSSEEvent(w, flusher, \"response.content_part.added\", map[string]any{\n\t\t\t\t\t\"type\": \"response.content_part.added\",\n\t\t\t\t\t\"sequence_number\": seq,\n\t\t\t\t\t\"item_id\": msgID,\n\t\t\t\t\t\"output_index\": idx,\n\t\t\t\t\t\"content_index\": 0,\n\t\t\t\t\t\"part\": map[string]any{\"type\": \"output_text\", \"annotations\": []any{}, \"logprobs\": []any{}, \"text\": \"\"},\n\t\t\t\t})\n\t\t\t\tmessageStarted = true\n\t\t\t}\n\t\t\temitMessageDone()\n\t\t\tfor _, idx := range toolOrder {\n\t\t\t\temitToolCallDone(toolCalls[idx][\"output_index\"].(int), toolCalls[idx])\n\t\t\t}\n\t\t}\n\t}\n\n\temitReasoningDone()\n\temitMessageDone()\n\tfor _, idx := range toolOrder {\n\t\temitToolCallDone(toolCalls[idx][\"output_index\"].(int), toolCalls[idx])\n\t}\n\n\toutput := make([]any, indexAllocator.Len())\n\tif reasoningStarted {\n\t\toutput[reasoningOutputIndex] = reasoningItem(itemStatus)\n\t}\n\tif messageStarted {\n\t\toutput[messageIndex] = messageItem(itemStatus)\n\t}\n\tfor _, idx := range toolOrder {\n\t\tcall := toolCalls[idx]\n\t\titemType, _ := call[\"item_type\"].(string)\n\t\tif itemType == \"\" {\n\t\t\titemType = \"function_call\"\n\t\t}\n\t\titem := buildResponseToolCallItem(ToolCall{\n\t\t\tID: call[\"call_id\"].(string),\n\t\t\tFunction: FunctionCall{\n\t\t\t\tName: call[\"name\"].(string),\n\t\t\t\tArguments: call[\"arguments\"].(string),\n\t\t\t},\n\t\t}, itemType)\n\t\titem[\"status\"] = itemStatus\n\t\toutput[call[\"output_index\"].(int)] = item\n\t}\n\n\tcompletedResponse := map[string]any{\n\t\t\"id\": responseID,\n\t\t\"object\": \"response\",\n\t\t\"created_at\": createdAt,\n\t\t\"status\": terminalStatus,\n\t\t\"background\": false,\n\t\t\"error\": nil,\n\t\t\"incomplete_details\": nil,\n\t\t\"model\": model,\n\t\t\"output\": output,\n\t}\n\tif terminalStatus == \"incomplete\" {\n\t\tcompletedResponse[\"incomplete_details\"] = map[string]any{\"reason\": \"max_output_tokens\"}\n\t}\n\tapplyResponsesRequestEcho(completedResponse, originalReq)\n\tif len(tools) > 0 {\n\t\tcompletedResponse[\"tools\"] = tools\n\t}\n\tif toolChoice != nil {\n\t\tcompletedResponse[\"tool_choice\"] = toolChoice\n\t}\n\n\tif len(totalUsage) > 0 {\n\t\tusage := map[string]any{}\n\t\tif v, ok := totalUsage[\"prompt_tokens\"]; ok {\n\t\t\tusage[\"input_tokens\"] = v\n\t\t}\n\t\tif v, ok := totalUsage[\"prompt_tokens_details\"]; ok {\n\t\t\tusage[\"input_tokens_details\"] = v\n\t\t} else {\n\t\t\tusage[\"input_tokens_details\"] = map[string]any{\"cached_tokens\": 0}\n\t\t}\n\t\tif v, ok := totalUsage[\"completion_tokens\"]; ok {\n\t\t\tusage[\"output_tokens\"] = v\n\t\t}\n\t\tif v, ok := totalUsage[\"completion_tokens_details\"]; ok {\n\t\t\tusage[\"output_tokens_details\"] = v\n\t\t}\n\t\tif v, ok := totalUsage[\"total_tokens\"]; ok {\n\t\t\tusage[\"total_tokens\"] = v\n\t\t}\n\t\tif v, ok := totalUsage[\"input_tokens\"]; ok && usage[\"input_tokens\"] == nil {\n\t\t\tusage[\"input_tokens\"] = v\n\t\t}\n\t\tif v, ok := totalUsage[\"output_tokens\"]; ok && usage[\"output_tokens\"] == nil {\n\t\t\tusage[\"output_tokens\"] = v\n\t\t}\n\t\tcompletedResponse[\"usage\"] = usage\n\t}\n\n\tif totalUsage != nil {\n\t\tpt, _ := totalUsage[\"prompt_tokens\"].(float64)\n\t\tct, _ := totalUsage[\"completion_tokens\"].(float64)\n\t\ttt, _ := totalUsage[\"total_tokens\"].(float64)\n\t\tif tt > 0 {\n\t\t\trecordTokenUsage(model, int64(pt), int64(ct), int64(tt))\n\t\t}\n\t}\n\n\tseq++\n\temitSSEEvent(w, flusher, terminalEvent, map[string]any{\n\t\t\"type\": terminalEvent,\n\t\t\"sequence_number\": seq,\n\t\t\"response\": completedResponse,\n\t})\n\n\tif flusher != nil {\n\t\tflusher.Flush()\n\t}\n\tstoreResponseState(completedResponse, originalReq)\n}\n\nfunc convertChatToResponses(chatBody []byte, model string, wantReasoning bool, tools []ResponsesTool, toolChoice any) []byte {\n\tvar chat struct {\n\t\tID string `json:\"id\"`\n\t\tCreated int64 `json:\"created\"`\n\t\tChoices []struct {\n\t\t\tFinishReason string `json:\"finish_reason\"`\n\t\t\tMessage struct {\n\t\t\t\tContent any `json:\"content\"`\n\t\t\t\tRefusal string `json:\"refusal\"`\n\t\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\t\tToolCalls []ToolCall `json:\"tool_calls\"`\n\t\t\t} `json:\"message\"`\n\t\t} `json:\"choices\"`\n\t\tUsage map[string]any `json:\"usage\"`\n\t}\n\tif err := json.Unmarshal(chatBody, &chat); err != nil {\n\t\tslog.Warn(\"convertChatToResponses unmarshal failed\", \"error\", err)\n\t}\n\n\treasoning := \"\"\n\tfinishReason := \"\"\n\tvar toolCalls []ToolCall\n\tmessageContent := []any(nil)\n\ttoolKinds := responsesToolKindMap(tools)\n\tif len(chat.Choices) > 0 {\n\t\tmessageContent, _ = chatContentToResponsesContent(chat.Choices[0].Message.Content)\n\t\tif refusal := chat.Choices[0].Message.Refusal; refusal != \"\" {\n\t\t\tmessageContent = []any{map[string]any{\"type\": \"refusal\", \"refusal\": refusal}}\n\t\t}\n\t\trc := chat.Choices[0].Message.ReasoningContent\n\t\tif wantReasoning {\n\t\t\treasoning = rc\n\t\t}\n\t\ttoolCalls = chat.Choices[0].Message.ToolCalls\n\t\tfinishReason = chat.Choices[0].FinishReason\n\t\tif len(messageContent) == 0 && rc != \"\" && len(toolCalls) == 0 {\n\t\t\tmessageContent, _ = chatContentToResponsesContent(rc)\n\t\t}\n\t}\n\n\toutcome := responsesOutcome(finishReason)\n\tstatus := outcome.Status\n\tresponses := map[string]any{\n\t\t\"id\": chat.ID,\n\t\t\"object\": \"response\",\n\t\t\"status\": status,\n\t\t\"background\": false,\n\t\t\"error\": nil,\n\t\t\"incomplete_details\": outcome.IncompleteDetails,\n\t\t\"model\": model,\n\t\t\"created_at\": chat.Created,\n\t}\n\tif len(tools) > 0 {\n\t\tresponses[\"tools\"] = tools\n\t}\n\tif toolChoice != nil {\n\t\tresponses[\"tool_choice\"] = toolChoice\n\t}\n\toutputID := \"msg_\" + chat.ID + \"_0\"\n\toutput := []any{}\n\tif reasoning != \"\" {\n\t\toutput = append(output, map[string]any{\n\t\t\t\"id\": \"rs_\" + chat.ID,\n\t\t\t\"type\": \"reasoning\",\n\t\t\t\"encrypted_content\": \"\",\n\t\t\t\"summary\": []any{map[string]any{\"type\": \"summary_text\", \"text\": reasoning}},\n\t\t})\n\t}\n\tif len(messageContent) > 0 {\n\t\toutput = append(output, map[string]any{\n\t\t\t\"id\": outputID,\n\t\t\t\"type\": \"message\",\n\t\t\t\"status\": status,\n\t\t\t\"role\": \"assistant\",\n\t\t\t\"content\": messageContent,\n\t\t})\n\t}\n\tfor _, tc := range toolCalls {\n\t\titem := buildResponseToolCallItem(tc, toolCallOutputType(tc.Function.Name, toolKinds))\n\t\titem[\"status\"] = status\n\t\toutput = append(output, item)\n\t}\n\tresponses[\"output\"] = output\n\tif chat.Usage != nil {\n\t\tusage := map[string]any{}\n\t\tif v, ok := chat.Usage[\"prompt_tokens\"]; ok {\n\t\t\tusage[\"input_tokens\"] = v\n\t\t}\n\t\tif v, ok := chat.Usage[\"prompt_tokens_details\"]; ok {\n\t\t\tusage[\"input_tokens_details\"] = v\n\t\t} else {\n\t\t\tusage[\"input_tokens_details\"] = map[string]any{\"cached_tokens\": 0}\n\t\t}\n\t\tif v, ok := chat.Usage[\"completion_tokens\"]; ok {\n\t\t\tusage[\"output_tokens\"] = v\n\t\t}\n\t\tif v, ok := chat.Usage[\"completion_tokens_details\"]; ok {\n\t\t\tusage[\"output_tokens_details\"] = v\n\t\t}\n\t\tif v, ok := chat.Usage[\"total_tokens\"]; ok {\n\t\t\tusage[\"total_tokens\"] = v\n\t\t}\n\t\tif v, ok := chat.Usage[\"input_tokens\"]; ok && usage[\"input_tokens\"] == nil {\n\t\t\tusage[\"input_tokens\"] = v\n\t\t}\n\t\tif v, ok := chat.Usage[\"output_tokens\"]; ok && usage[\"output_tokens\"] == nil {\n\t\t\tusage[\"output_tokens\"] = v\n\t\t}\n\t\tresponses[\"usage\"] = usage\n\t}\n\n\tresult, _ := json.Marshal(responses)\n\treturn result\n}\n\nfunc emitSSEEvent(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) {\n\tjsonData, err := json.Marshal(data)\n\tif err != nil {\n\t\tslog.Error(\"marshal SSE event failed\", \"error\", err)\n\t\treturn\n\t}\n\tw.Write([]byte(\"event: \" + event + \"\\n\"))\n\tw.Write([]byte(\"data: \" + string(jsonData) + \"\\n\\n\"))\n\tif flusher != nil {\n\t\tflusher.Flush()\n\t}\n}\n\n// ======================== Admin 管理页面 ========================\n\nfunc reloadHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\trefreshOCSession()\n\tfetched, err := fetchModels()\n\tif err == nil && len(fetched) > 0 {\n\t\tmodelMu.Lock()\n\t\tmodelsCache = fetched\n\t\tmodelsLoaded = true\n\t\tmodelMu.Unlock()\n\t\tslog.Info(\"free models refreshed\", \"count\", len(fetched))\n\t}\n\tgoFetched, goErr := fetchGoModels()\n\tif goErr == nil && len(goFetched) > 0 {\n\t\tmodelMu.Lock()\n\t\tgoModelsCache = goFetched\n\t\tmodelMu.Unlock()\n\t\tslog.Info(\"go catalog refreshed\", \"count\", len(goFetched))\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\": \"ok\",\n\t\t\"session\": ocSessionID,\n\t\t\"free\": len(modelsCache),\n\t\t\"go\": len(goModelsCache),\n\t})\n\n}\nfunc adminConfigHandler(w http.ResponseWriter, r *http.Request) {\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\tconfigMu.RLock()\n\t\tcfg := AppConfig{ModelAlias: modelAlias, ReasoningEffortMap: reasoningEffortMap, ForceDisableThinking: forceDisableThinking}\n\t\tconfigMu.RUnlock()\n\t\tsocks5Mu.RLock()\n\t\tcfg.Socks5Proxies = socks5Proxies\n\t\tcfg.ActiveSocks5 = activeSocks5\n\t\tcfg.Socks5PaidDirect = socks5PaidDirect\n\t\tsocks5Mu.RUnlock()\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"model_alias\": cfg.ModelAlias,\n\t\t\t\"reasoning_effort_map\": cfg.ReasoningEffortMap,\n\t\t\t\"force_disable_thinking\": cfg.ForceDisableThinking,\n\t\t\t\"socks5_proxies\": cfg.Socks5Proxies,\n\t\t\t\"active_socks5\": cfg.ActiveSocks5,\n\t\t\t\"socks5_paid_direct\": cfg.Socks5PaidDirect,\n\t\t\t\"log_level\": getLogLevelString(),\n\t\t\t\"log_bodies\": getLogBodies(),\n\t\t})\n\tcase http.MethodPost:\n\t\tvar payload struct {\n\t\t\tAppConfig\n\t\t\tLogLevel *string `json:\"log_level,omitempty\"`\n\t\t\tLogBodies *bool `json:\"log_bodies,omitempty\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&payload); err != nil {\n\t\t\thttp.Error(w, `{\"error\":\"Invalid JSON\"}`, http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif err := saveConfig(configPath, payload.AppConfig); err != nil {\n\t\t\thttp.Error(w, `{\"error\":\"Failed to save config\"}`, http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tapplyConfig(payload.AppConfig)\n\t\tif payload.LogLevel != nil {\n\t\t\tsetLogLevelString(*payload.LogLevel)\n\t\t}\n\t\tif payload.LogBodies != nil {\n\t\t\tsetLogBodies(*payload.LogBodies)\n\t\t}\n\t\tif debugMode {\n\t\t\tslog.Info(\"config updated\",\n\t\t\t\t\"aliases\", len(payload.ModelAlias),\n\t\t\t\t\"effort_map\", len(payload.ReasoningEffortMap),\n\t\t\t\t\"force_disable\", payload.ForceDisableThinking,\n\t\t\t\t\"log_level\", getLogLevelString(),\n\t\t\t\t\"log_bodies\", getLogBodies(),\n\t\t\t)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n\tdefault:\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\nfunc adminStatsHandler(w http.ResponseWriter, r *http.Request) {\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\ttokenStatsMu.Lock()\n\t\tdata, err := json.Marshal(tokenStats)\n\t\ttokenStatsMu.Unlock()\n\t\tif err != nil {\n\t\t\thttp.Error(w, `{\"error\":\"marshal error\"}`, http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Write(data)\n\tcase http.MethodDelete:\n\t\ttokenStatsMu.Lock()\n\t\ttokenStats = &TokenStatsData{Models: map[string]*ModelStats{}}\n\t\ttokenStatsMu.Unlock()\n\t\tsaveTokenStats()\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n\tdefault:\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\nfunc adminPageHandler(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tw.Write([]byte(adminHTML))\n}\n\nfunc renderLoginPage(w http.ResponseWriter, msg string) {\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tw.Write([]byte(adminLoginHTML))\n\tif msg != \"\" {\n\t\tw.Write([]byte(\"<script>document.addEventListener('DOMContentLoaded',function(){var m=document.getElementById('login-msg');if(m){m.textContent='\" + msg + \"';m.style.display='block'}})</script>\"))\n\t}\n}\n\nconst adminLoginHTML = `<!DOCTYPE html>\n<html lang=\"zh\" data-theme=\"light\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>登录 — OPENCODE TO API</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n<style>\n:root{--bg:#f4f6fa;--surface:#fff;--border:#e2e6ed;--text:#1a1d26;--text-sec:#6a7180;--accent:#6c8aff;--accent-hover:#5a78f0;--radius:12px;--radius-sm:8px;--font:'Noto Sans SC',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',Consolas,monospace}\n[data-theme=\"dark\"]{--bg:#0c0e14;--surface:#14161e;--border:#252835;--text:#e8eaf0;--text-sec:#8b90a5;--accent:#6c8aff;--accent-hover:#5a78f0}\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}\nbody::before{content:'';position:fixed;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at 30% 20%,rgba(108,138,255,.04) 0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(61,214,140,.03) 0%,transparent 50%);pointer-events:none;z-index:0}\n.container{max-width:400px;width:100%;position:relative;z-index:1}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:36px 32px 32px}\n.logo{display:flex;align-items:center;gap:10px;margin-bottom:6px}\n.logo-mark{width:36px;height:36px;background:linear-gradient(135deg,var(--accent),#8b6cff);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;flex-shrink:0}\n.logo-text{font-size:20px;font-weight:700;letter-spacing:-.5px;background:linear-gradient(135deg,var(--text),var(--text-sec));-webkit-background-clip:text;-webkit-text-fill-color:transparent}\n.logo-sub{font-size:12px;color:var(--text-sec);margin-top:2px}\n.subtitle{font-size:13px;color:var(--text-sec);margin-bottom:28px;margin-top:4px}\n.field{margin-bottom:16px}\n.field label{display:block;font-size:12px;font-weight:500;color:var(--text-sec);margin-bottom:6px;letter-spacing:.3px}\n.field input{width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:14px;font-family:var(--mono);background:var(--surface);color:var(--text);transition:border-color .15s,box-shadow .15s}\n.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(108,138,255,.1)}\n.msg{display:none;background:rgba(240,96,96,.1);color:#d64545;padding:10px 14px;border-radius:var(--radius-sm);margin-bottom:16px;font-size:13px;text-align:center;border:1px solid rgba(240,96,96,.2)}\n[data-theme=\"dark\"] .msg{color:#f06060}\n.btn{width:100%;padding:10px;border:none;border-radius:var(--radius-sm);font-size:14px;font-weight:600;cursor:pointer;font-family:var(--font);background:var(--accent);color:#fff;transition:background .15s}\n.btn:hover{background:var(--accent-hover)}\n.theme-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}\n.theme-toggle{background:transparent;border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 12px;cursor:pointer;font-size:13px;color:var(--text-sec);font-family:var(--font);transition:all .15s}\n.theme-toggle:hover{border-color:var(--accent);color:var(--accent)}\n@media(max-width:500px){.card{padding:24px 20px}}\n</style>\n</head>\n<body>\n<div class=\"container\">\n<div class=\"card\">\n<div class=\"theme-bar\">\n<div class=\"logo\">\n<div class=\"logo-mark\">⌨</div>\n<div>\n<div class=\"logo-text\">OPENCODE TO API</div>\n<div class=\"logo-sub\">管理面板</div>\n</div>\n</div>\n<button class=\"theme-toggle\" onclick=\"toggleTheme()\">☀</button>\n</div>\n<div class=\"subtitle\">请输入管理密码以继续</div>\n<div class=\"msg\" id=\"login-msg\"></div>\n<form method=\"post\" action=\"/login\">\n<div class=\"field\">\n<label for=\"pwd\">密码</label>\n<input id=\"pwd\" name=\"password\" type=\"password\" placeholder=\"输入管理密码\" autocomplete=\"current-password\" required>\n</div>\n<button class=\"btn\" type=\"submit\">登录</button>\n</form>\n</div>\n</div>\n<script>\n(function(){var t=localStorage.getItem('theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark')}})();\nfunction toggleTheme(){var d=document.documentElement;var n=d.getAttribute('data-theme')==='dark'?'light':'dark';if(n==='dark')d.setAttribute('data-theme','dark');else d.removeAttribute('data-theme');localStorage.setItem('theme',n);document.querySelector('.theme-toggle').textContent=n==='dark'?'🌙':'☀'}\n</script>\n</body>\n</html>`\n\nconst adminHTML = `<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>OPENCODE TO API 管理面板</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n<style>\n:root {\n --bg: #f4f6fa;\n --surface: #ffffff;\n --surface-2: #f0f2f7;\n --border: #e2e6ed;\n --border-light: #d0d4df;\n --text: #1a1d26;\n --text-sec: #6a7180;\n --text-ter: #9ca3b0;\n --accent: #6c8aff;\n --accent-dim: rgba(108,138,255,.08);\n --accent-hover: #5a78f0;\n --green: #22a85a;\n --green-dim: rgba(34,168,90,.08);\n --green-hover: #1d9850;\n --orange: #d9600a;\n --orange-dim: rgba(217,96,10,.08);\n --orange-hover: #c45507;\n --red: #dc2626;\n --red-dim: rgba(220,38,38,.08);\n --radius: 12px;\n --radius-sm: 8px;\n --font: 'Noto Sans SC', system-ui, -apple-system, sans-serif;\n --mono: 'JetBrains Mono', Consolas, monospace;\n --glow-a: rgba(108,138,255,.03);\n --glow-b: rgba(61,214,140,.02);\n --stats-total-bg: #f0f2f7;\n}\n[data-theme=\"dark\"] {\n --bg: #0c0e14;\n --surface: #14161e;\n --surface-2: #1a1d27;\n --border: #252835;\n --border-light: #2e3142;\n --text: #e8eaf0;\n --text-sec: #8b90a5;\n --text-ter: #5c6080;\n --accent: #6c8aff;\n --accent-dim: rgba(108,138,255,.12);\n --accent-hover: #5a78f0;\n --green: #3dd68c;\n --green-dim: rgba(61,214,140,.12);\n --green-hover: #30c47a;\n --orange: #f0a050;\n --orange-dim: rgba(240,160,80,.12);\n --orange-hover: #e09040;\n --red: #f06060;\n --red-dim: rgba(240,96,96,.12);\n --glow-a: rgba(108,138,255,.04);\n --glow-b: rgba(61,214,140,.03);\n --stats-total-bg: var(--surface-2);\n}\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}\nbody::before{content:'';position:fixed;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(ellipse at 30% 20%,var(--glow-a) 0%,transparent 50%),radial-gradient(ellipse at 70% 80%,var(--glow-b) 0%,transparent 50%);pointer-events:none;z-index:0}\n.container{max-width:1020px;margin:0 auto;padding:32px 24px;position:relative;z-index:1}\nheader{display:flex;align-items:flex-end;gap:16px;margin-bottom:28px;padding-bottom:20px;border-bottom:1px solid var(--border);justify-content:space-between}\n.logo{display:flex;align-items:center;gap:10px}\n.logo-mark{width:36px;height:36px;background:linear-gradient(135deg,var(--accent),#8b6cff);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;flex-shrink:0}\n.logo-text{font-size:22px;font-weight:700;letter-spacing:-.5px;background:linear-gradient(135deg,var(--text),var(--text-sec));-webkit-background-clip:text;-webkit-text-fill-color:transparent}\n.logo-sub{font-size:12.5px;color:var(--text-ter);margin-bottom:2px}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:22px 24px;transition:border-color .2s}\n.card:hover{border-color:var(--border-light)}\n.card h2{font-size:13px;font-weight:600;margin-bottom:16px;letter-spacing:.2px;display:flex;align-items:center;gap:8px;color:var(--text-sec);text-transform:uppercase}\n.card h2 .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}\n.config-grid{display:grid;grid-template-columns:2fr 3fr;gap:16px;margin-top:16px}\n.config-grid .card{margin-bottom:0}\n.full-row{grid-column:1/-1}\n.form-group{margin-bottom:14px}\n.form-group:last-child{margin-bottom:0}\n.form-group label{display:block;font-size:11.5px;font-weight:500;color:var(--text-ter);margin-bottom:5px;letter-spacing:.4px;text-transform:uppercase}\n.form-group input[type=\"text\"],.form-group input[type=\"url\"],.form-group input[type=\"password\"],.form-group textarea,.form-group select,.m-select{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:var(--mono);background:var(--surface-2);color:var(--text);transition:border-color .15s,box-shadow .15s}\n.form-group input:focus,.form-group textarea:focus,.form-group select:focus,.m-select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}\n.form-group .hint{font-size:11px;color:var(--text-ter);margin-top:4px;line-height:1.4}\n.actions{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}\n.btn{padding:8px 16px;border-radius:var(--radius-sm);font-size:12.5px;font-weight:500;cursor:pointer;border:none;transition:all .15s;font-family:var(--font);white-space:nowrap}\n.btn-primary{background:var(--accent-dim);color:var(--accent)}\n.btn-primary:hover{background:var(--accent);color:#fff}\n.btn-default{background:var(--surface-2);color:var(--text-sec);border:1px solid var(--border)}\n.btn-default:hover{border-color:var(--border-light);color:var(--text)}\n.btn-success{background:var(--green-dim);color:var(--green)}\n.btn-success:hover{background:var(--green);color:#fff}\n.btn-warning{background:var(--orange-dim);color:var(--orange)}\n.btn-warning:hover{background:var(--orange);color:#fff}\n.btn-danger{background:var(--red-dim);color:var(--red)}\n.btn-danger:hover{background:var(--red);color:#fff}\n.tbl{width:100%;border-collapse:collapse;font-size:12.5px}\n.tbl th{text-align:left;font-weight:500;color:var(--text-ter);padding:8px 10px;border-bottom:1px solid var(--border);font-size:11px;letter-spacing:.4px;text-transform:uppercase;white-space:nowrap}\n.tbl td{padding:7px 10px;border-bottom:1px solid var(--border)}\n.tbl tr:last-child td{border-bottom:none}\n.tbl input{width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-size:12.5px;font-family:var(--mono);background:var(--surface-2);color:var(--text);transition:border-color .15s,box-shadow .15s}\n.tbl input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}\n.tbl .m-select{padding:6px 10px;font-size:12.5px}\n.tbl th:last-child{width:52px}\n.tbl td:last-child{white-space:nowrap;text-align:center}\n#statsTable th:last-child{width:auto}\n#statsTable td:last-child{text-align:left;white-space:nowrap}\n.tbl .btn{padding:4px 10px;font-size:11px;white-space:nowrap}\n#statsTable td:first-child{font-weight:500;color:var(--text)}\n#statsTable td:not(:first-child){font-family:var(--mono);color:var(--text-sec);text-align:left}\n#statsTable tbody tr:hover{background:var(--surface-2)}\n#statsTable thead+tbody tr:last-child td{font-weight:600;color:var(--text);background:var(--stats-total-bg);border-top:1px solid var(--border-light)}\n.stats-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:12px}\n.stats-header .btns{display:flex;gap:6px;align-items:center}\n#toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;color:#fff;opacity:0;transition:opacity .25s,transform .25s;z-index:999;transform:translateY(-8px);pointer-events:none;backdrop-filter:blur(8px)}\n#toast.success{background:rgba(61,214,140,.85)}\n#toast.error{background:rgba(240,96,96,.85)}\n#toast.show{opacity:1;transform:translateY(0)}\n.empty-hint{color:var(--text-ter);font-size:13px;padding:28px;text-align:center}\n.think-row{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:12px;transition:border-color .15s}\n.think-row:hover{border-color:var(--border-light)}\n.think-row input[type=\"checkbox\"]{width:16px;height:16px;accent-color:var(--accent);cursor:pointer}\n.think-row label{font-size:13px;font-weight:500;cursor:pointer;margin:0;color:var(--text)}\n.think-row .hint{font-size:11px;color:var(--text-ter);margin:0 0 0 auto;white-space:nowrap}\n@media(max-width:700px){.config-grid{grid-template-columns:1fr}.container{padding:16px 12px}header{flex-direction:column;align-items:flex-start;gap:8px}}\n.theme-toggle{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 12px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .15s;color:var(--text-sec);flex-shrink:0;line-height:1}\n.theme-toggle:hover{border-color:var(--border-light);color:var(--text)}\n</style>\n</head>\n<body>\n<div class=\"container\">\n<header>\n<div class=\"logo\">\n<div class=\"logo-mark\">⌨</div>\n<div>\n<div class=\"logo-text\">OPENCODE TO API</div>\n<div class=\"logo-sub\">OpenCode 免费 API → 兼容格式代理</div>\n</div>\n</div>\n<div style=\"display:flex;align-items:center;gap:8px\">\n<button class=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"切换主题\">☀</button>\n<form method=\"post\" action=\"/logout\" style=\"margin:0\"><button class=\"theme-toggle\" type=\"submit\" title=\"退出登录\" style=\"font-size:14px\">退出</button></form>\n</div>\n</header>\n\n<div class=\"card\">\n<div class=\"stats-header\">\n<h2><span class=\"dot\" style=\"background:var(--green)\"></span>Token 统计</h2>\n<div class=\"btns\">\n<button class=\"btn btn-success\" onclick=\"reloadConfig()\">刷新</button>\n<button class=\"btn btn-danger\" onclick=\"resetStats()\">清空统计</button>\n<span id=\"resetStatus\" style=\"font-size:11px;color:var(--text-ter)\"></span>\n</div>\n</div>\n<div id=\"statsContent\" style=\"font-size:12.5px\">\n<div class=\"empty-hint\">加载中...</div>\n</div>\n</div>\n\n<div class=\"config-grid\">\n<div class=\"card\">\n<h2><span class=\"dot\" style=\"background:var(--orange)\"></span>推理力度映射</h2>\n<div style=\"margin-bottom:12px\">\n<table class=\"tbl\" id=\"effortTable\">\n<thead><tr><th style=\"width:35%\">请求值</th><th style=\"width:42%\">映射值</th><th style=\"width:23%\"></th></tr></thead>\n<tbody></tbody>\n</table>\n</div>\n<div class=\"think-row\">\n<input type=\"checkbox\" id=\"force_disable_thinking\">\n<label for=\"force_disable_thinking\">强制禁用思考模式</label>\n<span class=\"hint\">移除所有推理内容</span>\n</div>\n<div class=\"actions\">\n<button class=\"btn btn-primary\" onclick=\"addEffortRow()\">添加映射</button>\n<button class=\"btn btn-success\" onclick=\"saveConfig()\">保存全部</button>\n</div>\n</div>\n\n<div class=\"card\">\n<h2><span class=\"dot\" style=\"background:var(--accent)\"></span>模型映射</h2>\n<div style=\"margin-bottom:12px\">\n<table class=\"tbl\" id=\"aliasTable\">\n<thead><tr><th style=\"width:35%\">别名(请求名)</th><th style=\"width:42%\">实际模型(上游名)</th><th style=\"width:23%\"></th></tr></thead>\n<tbody></tbody>\n</table>\n</div>\n<div class=\"actions\">\n<button class=\"btn btn-primary\" onclick=\"addAliasRow()\">添加别名</button>\n<button class=\"btn btn-success\" onclick=\"saveConfig()\">保存全部</button>\n</div>\n</div>\n\n<div class=\"card full-row\">\n<h2><span class=\"dot\" style=\"background:var(--accent)\"></span>SOCKS5 代理</h2>\n<div style=\"margin-bottom:12px\">\n<table class=\"tbl\" id=\"socks5Table\">\n<thead><tr><th style=\"width:25%\">名称</th><th style=\"width:28%\">地址</th><th style=\"width:17%\">用户名</th><th style=\"width:17%\">密码</th><th style=\"width:13%\"></th></tr></thead>\n<tbody></tbody>\n</table>\n</div>\n<div class=\"form-group\">\n<label>启用代理</label>\n<select id=\"activeSocks5\" class=\"m-select\">\n<option value=\"\">直连(不使用代理)</option>\n</select>\n</div>\n<label class=\"check\"><input type=\"checkbox\" id=\"socks5_paid_direct\"> 带 key / 付费请求直连(不走 SOCKS5)</label>\n<p style=\"margin:6px 0 12px;color:var(--muted);font-size:13px\">默认关闭:只要启用了代理,public 与带 key 请求都走 SOCKS5。</p>\n<div class=\"actions\">\n<button class=\"btn btn-primary\" onclick=\"addSocks5Row()\">添加代理</button>\n<button class=\"btn btn-success\" onclick=\"saveConfig()\">保存全部</button>\n</div>\n</div>\n</div>\n</div>\n<div id=\"toast\"></div>\n<script>\nlet aliasData={},effortData={},modelList=[],socks5Data=[];\nfunction toggleTheme(){const d=document.documentElement;const cur=d.getAttribute('data-theme');const next=cur==='dark'?null:'dark';if(next)d.setAttribute('data-theme',next);else d.removeAttribute('data-theme');localStorage.setItem('theme',next||'light');document.querySelector('.theme-toggle').textContent=next==='dark'?'🌙':'☀'}\n(function(){const t=localStorage.getItem('theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark');document.addEventListener('DOMContentLoaded',()=>{const b=document.querySelector('.theme-toggle');if(b)b.textContent='🌙'})}})();\nfunction reloadConfig(){const sy=window.scrollY;fetch('/api/reload',{method:'POST'}).then(r=>r.json()).then(d=>{showToast('会话已刷新,模型 '+d.models+' 个','success')}).catch(()=>{}).finally(()=>{loadConfig();loadStats();setTimeout(()=>window.scrollTo(0,sy),100)})}\nasync function loadConfig(){const sy=window.scrollY;try{const r=await fetch('/api/config');const cfg=await r.json();document.getElementById('force_disable_thinking').checked=cfg.force_disable_thinking||false;document.getElementById('socks5_paid_direct').checked=!!cfg.socks5_paid_direct;aliasData=cfg.model_alias||{};effortData=cfg.reasoning_effort_map||{};socks5Data=cfg.socks5_proxies||[];const mr=await fetch('/v1/models');const md=await mr.json();modelList=(md.data||[]).map(m=>m.id).sort();renderAliasTable();renderEffortTable();renderSocks5Table();document.getElementById('activeSocks5').value=cfg.active_socks5||'';setTimeout(()=>window.scrollTo(0,sy),0)}catch(e){showToast('失败: '+e.message,'error')}}\nfunction renderAliasTable(){const tb=document.querySelector('#aliasTable tbody');const ks=Object.keys(aliasData);if(!ks.length){tb.innerHTML='<tr><td colspan=\"3\" class=\"empty-hint\">暂无别名配置</td></tr>';return}tb.innerHTML=ks.map(k=>'<tr><td><input value=\"'+esc(k)+'\" data-field=\"key\"></td><td>'+modelSelectHtml(aliasData[k])+'</td><td><button class=\"btn btn-danger\" onclick=\"delAlias(this)\">删除</button></td></tr>').join('')}\nfunction modelSelectHtml(selected){let h='<select data-field=\"val\" class=\"m-select\">';h+='<option value=\"\">-- 选择模型 --</option>';for(const m of modelList){h+='<option value=\"'+esc(m)+'\"'+(selected===m?' selected':'')+'>'+esc(m)+'</option>'}h+='</select>';return h}\nfunction addAliasRow(){const tb=document.querySelector('#aliasTable tbody');if(tb.querySelector('.empty-hint'))tb.innerHTML='';tb.insertAdjacentHTML('beforeend','<tr><td><input value=\"\" placeholder=\"例如: gpt-5.5\" data-field=\"key\"></td><td>'+modelSelectHtml('')+'</td><td><button class=\"btn btn-danger\" onclick=\"delAlias(this)\">删除</button></td></tr>')}\nfunction delAlias(btn){const row=btn.closest('tr');const ki=row.querySelector('[data-field=\"key\"]');if(ki&&ki.value&&aliasData[ki.value])delete aliasData[ki.value];row.remove();if(!Object.keys(aliasData).length)document.querySelector('#aliasTable tbody').innerHTML='<tr><td colspan=\"3\" class=\"empty-hint\">暂无别名配置</td></tr>'}\nfunction collectAliases(){const r={};document.querySelectorAll('#aliasTable tbody tr').forEach(tr=>{const k=tr.querySelector('[data-field=\"key\"]'),v=tr.querySelector('[data-field=\"val\"]');if(k&&k.value.trim())r[k.value.trim()]=v?v.value.trim():''});aliasData=r;return r}\nfunction renderEffortTable(){const tb=document.querySelector('#effortTable tbody');const ks=Object.keys(effortData);if(!ks.length){tb.innerHTML='<tr><td colspan=\"3\" class=\"empty-hint\">暂无映射配置</td></tr>';return}tb.innerHTML=ks.map(k=>'<tr><td><input value=\"'+esc(k)+'\" data-field=\"key\"></td><td><input value=\"'+esc(effortData[k])+'\" data-field=\"val\"></td><td><button class=\"btn btn-danger\" onclick=\"delEffort(this)\">删除</button></td></tr>').join('')}\nfunction addEffortRow(){const tb=document.querySelector('#effortTable tbody');if(tb.querySelector('.empty-hint'))tb.innerHTML='';tb.insertAdjacentHTML('beforeend','<tr><td><input value=\"\" placeholder=\"例如: low\" data-field=\"key\"></td><td><input value=\"\" placeholder=\"例如: high\" data-field=\"val\"></td><td><button class=\"btn btn-danger\" onclick=\"delEffort(this)\">删除</button></td></tr>')}\nfunction delEffort(btn){const row=btn.closest('tr');const ki=row.querySelector('[data-field=\"key\"]');if(ki&&ki.value&&effortData[ki.value])delete effortData[ki.value];row.remove();if(!Object.keys(effortData).length)document.querySelector('#effortTable tbody').innerHTML='<tr><td colspan=\"3\" class=\"empty-hint\">暂无映射配置</td></tr>'}\nfunction collectEfforts(){const r={};document.querySelectorAll('#effortTable tbody tr').forEach(tr=>{const k=tr.querySelector('[data-field=\"key\"]'),v=tr.querySelector('[data-field=\"val\"]');if(k&&k.value.trim())r[k.value.trim()]=v?v.value.trim():''});effortData=r;return r}\nfunction renderSocks5Table(){const tb=document.querySelector('#socks5Table tbody');if(!socks5Data.length){tb.innerHTML='<tr><td colspan=\"5\" class=\"empty-hint\">暂无代理配置</td></tr>';return}tb.innerHTML=socks5Data.map((p,i)=>'<tr><td><input value=\"'+esc(p.name||'')+'\" data-field=\"name\"></td><td><input value=\"'+esc(p.addr)+'\" data-field=\"addr\" placeholder=\"例如: 127.0.0.1:1080\"></td><td><input value=\"'+esc(p.username||'')+'\" data-field=\"username\"></td><td><input value=\"'+esc(p.password||'')+'\" data-field=\"password\" type=\"password\"></td><td><button class=\"btn btn-danger\" onclick=\"delSocks5('+i+')\">删除</button></td></tr>').join('');renderSocks5Select()}\nfunction addSocks5Row(){const tb=document.querySelector('#socks5Table tbody');if(tb.querySelector('.empty-hint'))tb.innerHTML='';socks5Data.push({addr:'',name:''});renderSocks5Table()}\nfunction delSocks5(i){socks5Data.splice(i,1);renderSocks5Table()}\nfunction collectSocks5(){const r=[];document.querySelectorAll('#socks5Table tbody tr').forEach(tr=>{const a=tr.querySelector('[data-field=\"addr\"]');if(a&&a.value.trim())r.push({addr:a.value.trim(),name:(tr.querySelector('[data-field=\"name\"]')||{}).value?.trim()||'',username:(tr.querySelector('[data-field=\"username\"]')||{}).value?.trim()||'',password:(tr.querySelector('[data-field=\"password\"]')||{}).value?.trim()||''})});socks5Data=r;return r}\nfunction renderSocks5Select(){const sel=document.getElementById('activeSocks5');const cur=sel.value;sel.innerHTML='<option value=\"\">直连(不使用代理)</option>';socks5Data.forEach(p=>{if(p.addr){const label=p.name?p.name+' ('+p.addr+')':p.addr;const opt=document.createElement('option');opt.value=p.addr;opt.textContent=label;sel.appendChild(opt)}});if(socks5Data.length>=2){const opt=document.createElement('option');opt.value='__round_robin__';opt.textContent='轮询(自动切换)';sel.appendChild(opt)}sel.value=cur;if(!sel.value)sel.value='';}\nasync function saveConfig(){collectAliases();collectEfforts();collectSocks5();const cfg={model_alias:aliasData,reasoning_effort_map:effortData,force_disable_thinking:document.getElementById('force_disable_thinking').checked,socks5_proxies:socks5Data,active_socks5:document.getElementById('activeSocks5').value,socks5_paid_direct:document.getElementById('socks5_paid_direct').checked};try{const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)});if(!r.ok)throw new Error(await r.text());showToast('配置已保存','success');loadConfig()}catch(e){showToast('保存失败: '+e.message,'error')}}\nfunction esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}\nfunction showToast(msg,t){const e=document.getElementById('toast');e.textContent=msg;e.className=t+' show';clearTimeout(e._tid);e._tid=setTimeout(()=>e.classList.remove('show'),2500)}\nasync function resetStats(){if(!confirm('确认清空所有 Token 统计?\\n此操作不可撤销。'))return;const s=document.getElementById('resetStatus');s.textContent='清空中...';try{const r=await fetch('/api/stats',{method:'DELETE'});if(!r.ok)throw new Error(await r.text());document.getElementById('statsContent').innerHTML='<div class=\"empty-hint\">暂无数据</div>';s.textContent='已清空';setTimeout(()=>s.textContent='',2000)}catch(e){s.textContent='失败: '+e.message}}\nasync function loadStats(){try{const r=await fetch('/api/stats');const d=await r.json();const ms=d.models||{};const ks=Object.keys(ms);let h='<table class=\"tbl\" id=\"statsTable\"><thead><tr><th>模型</th><th>请求数</th><th>输入 Token</th><th>输出 Token</th><th>总计 Token</th></tr></thead><tbody>';if(!ks.length){h+='<tr><td colspan=\"5\" class=\"empty-hint\">暂无数据</td></tr>'}else{let tr=0,pt=0,ct=0,tt=0;for(const k of ks){const m=ms[k];h+='<tr><td>'+esc(k)+'</td><td>'+fmt(m.request_count)+'</td><td>'+fmt(m.prompt_tokens)+'</td><td>'+fmt(m.completion_tokens)+'</td><td>'+fmt(m.total_tokens)+'</td></tr>';tr+=m.request_count;pt+=m.prompt_tokens;ct+=m.completion_tokens;tt+=m.total_tokens}h+='<tr><td>总计</td><td>'+fmt(tr)+'</td><td>'+fmt(pt)+'</td><td>'+fmt(ct)+'</td><td>'+fmt(tt)+'</td></tr>'}h+='</tbody></table>';document.getElementById('statsContent').innerHTML=h}catch(e){document.getElementById('statsContent').innerHTML='<div class=\"empty-hint\">加载失败</div>'}}\nfunction fmt(n){return n.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g,',')}window.onload=function(){loadConfig();loadStats()};setInterval(loadStats,5000);document.addEventListener('visibilitychange',function(){if(!document.hidden)loadStats()});\n</script>\n</body>\n</html>`\n\n// ======================== Main ========================\n\nfunc main() {\n\tvar showVersion bool\n\tflag.StringVar(&port, \"port\", \"8000\", \"服务端口\")\n\tflag.StringVar(&configPath, \"config\", \"config.json\", \"配置文件路径\")\n\tflag.StringVar(&adminPassword, \"password\", \"123456\", \"管理面板密码(留空则不启用登录验证)\")\n\tflag.BoolVar(&debugMode, \"debug\", false, \"启用调试日志\")\n\tflag.StringVar(&logLevel, \"log-level\", \"info\", \"日志级别: debug/info/warn/error\")\n\tflag.StringVar(&logFile, \"log-file\", \"opencode2api.log\", \"日志文件路径\")\n\tflag.BoolVar(&logStdout, \"log-stdout\", true, \"是否同时写 stdout\")\n\tflag.IntVar(&logMaxSize, \"log-max-size\", 100, \"单日志文件最大 MB,超过即轮换\")\n\tflag.IntVar(&logMaxBackups, \"log-max-backups\", 7, \"保留的旧日志文件个数\")\n\tflag.IntVar(&logMaxAge, \"log-max-age\", 14, \"旧日志保留天数\")\n\tflag.BoolVar(&logCompress, \"log-compress\", true, \"轮换后 gzip 压缩\")\n\tflag.BoolVar(&logBodies, \"log-bodies\", false, \"Debug 下记录截断的 body 摘要\")\n\tflag.BoolVar(&showVersion, \"version\", false, \"显示版本信息\")\n\tflag.Parse()\n\n\tinitLogger()\n\tdefer closeLogRotator()\n\n\tif showVersion {\n\t\tfmt.Println(versionString())\n\t\treturn\n\t}\n\n\tcfg := loadConfig(configPath)\n\tapplyConfig(cfg)\n\tif err := saveConfig(configPath, cfg); err != nil {\n\t\tslog.Warn(\"failed to save config\", \"path\", configPath, \"error\", err)\n\t}\n\n\tloadTokenStats()\n\tslog.Info(\"config loaded\", \"path\", configPath)\n\tinitOCSession()\n\tmodels, err := fetchModels()\n\tif err != nil {\n\t\tslog.Warn(\"failed to fetch models on startup\", \"error\", err)\n\t} else {\n\t\tmodelMu.Lock()\n\t\tmodelsCache = models\n\t\tmodelsLoaded = true\n\t\tmodelMu.Unlock()\n\t\tslog.Info(\"models loaded\", \"count\", len(models))\n\t}\n\n\tgoModels, goErr := fetchGoModels()\n\tif goErr != nil {\n\t\tslog.Warn(\"failed to fetch go catalog on startup\", \"error\", goErr)\n\t} else {\n\t\tmodelMu.Lock()\n\t\tgoModelsCache = goModels\n\t\tmodelMu.Unlock()\n\t\tslog.Info(\"go catalog loaded\", \"count\", len(goModels))\n\t}\n\tstartModelRefresh()\n\tslog.Info(\"server starting\",\n\t\t\"port\", port,\n\t\t\"log_level\", getLogLevelString(),\n\t\t\"models\", len(getModelIDs()),\n\t\t\"aliases\", len(modelAlias),\n\t)\n\tif adminPassword != \"\" {\n\t\tslog.Info(\"admin panel enabled\", \"url\", fmt.Sprintf(\"http://localhost:%s/\", port))\n\t} else {\n\t\tslog.Info(\"admin panel disabled (no password)\")\n\t}\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/v1/chat/completions\", loggingMiddleware(chatCompletionsHandler))\n\tmux.HandleFunc(\"/v1/responses\", loggingMiddleware(responsesHandler))\n\tmux.HandleFunc(\"/v1/messages\", loggingMiddleware(claudeMessagesHandler))\n\tmux.HandleFunc(\"/v1/models\", loggingMiddleware(listModelsHandler))\n\tmux.HandleFunc(\"/login\", loggingMiddleware(loginHandler))\n\tmux.HandleFunc(\"/logout\", loggingMiddleware(logoutHandler))\n\tmux.HandleFunc(\"/api/config\", loggingMiddleware(requireAuth(adminConfigHandler)))\n\tmux.HandleFunc(\"/api/stats\", loggingMiddleware(requireAuth(adminStatsHandler)))\n\tmux.HandleFunc(\"/api/reload\", loggingMiddleware(requireAuth(reloadHandler)))\n\tmux.HandleFunc(\"/health\", loggingMiddleware(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"OK\"))\n\t}))\n\tmux.HandleFunc(\"/\", loggingMiddleware(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\trequireAuth(adminPageHandler)(w, r)\n\t\t\treturn\n\t\t}\n\t\thttp.NotFound(w, r)\n\t}))\n\n\taddr := \":\" + port\n\tserver := &http.Server{Addr: addr, Handler: mux}\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo func() {\n\t\tslog.Info(\"listening\", \"addr\", addr)\n\t\tif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\tslog.Error(\"server terminated\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tslog.Info(\"shutting down\")\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tif err := server.Shutdown(shutdownCtx); err != nil {\n\t\tslog.Error(\"graceful shutdown failed\", \"error\", err)\n\t}\n}\n"}}}} |