178 lines
4.5 KiB
Go
178 lines
4.5 KiB
Go
package upstream
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// UpstreamError is any failure talking to the deepseek.* sites.
|
|
type UpstreamError struct {
|
|
Msg string
|
|
ContentStarted bool
|
|
Cause error
|
|
}
|
|
|
|
func (e *UpstreamError) Error() string {
|
|
if e.Msg != "" {
|
|
return e.Msg
|
|
}
|
|
if e.Cause != nil {
|
|
return e.Cause.Error()
|
|
}
|
|
return "upstream failed"
|
|
}
|
|
|
|
func (e *UpstreamError) Unwrap() error { return e.Cause }
|
|
|
|
// QuotaExhaustedError means the site ran out of free quota for this session.
|
|
type QuotaExhaustedError struct{ UpstreamError }
|
|
|
|
// SessionVerificationError means Turnstile/nonce verification must redone.
|
|
type SessionVerificationError struct{ UpstreamError }
|
|
|
|
func errf(format string, a ...any) *UpstreamError {
|
|
return &UpstreamError{Msg: fmt.Sprintf(format, a...)}
|
|
}
|
|
|
|
func newQuota(msg string) *QuotaExhaustedError {
|
|
return &QuotaExhaustedError{UpstreamError: UpstreamError{Msg: msg}}
|
|
}
|
|
|
|
func newSession(msg string) *SessionVerificationError {
|
|
return &SessionVerificationError{UpstreamError: UpstreamError{Msg: msg}}
|
|
}
|
|
|
|
// AsUpstream unwraps any error into an *UpstreamError.
|
|
func AsUpstream(err error) *UpstreamError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ue *UpstreamError
|
|
if errors.As(err, &ue) {
|
|
return ue
|
|
}
|
|
return &UpstreamError{Msg: err.Error(), Cause: err}
|
|
}
|
|
|
|
func contentStarted(err error) bool {
|
|
var ue *UpstreamError
|
|
if errors.As(err, &ue) {
|
|
return ue.ContentStarted
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isQuota(err error) bool {
|
|
var q *QuotaExhaustedError
|
|
return errors.As(err, &q)
|
|
}
|
|
|
|
func isSession(err error) bool {
|
|
var s *SessionVerificationError
|
|
return errors.As(err, &s)
|
|
}
|
|
|
|
// isSessionPayload classifies upstream JSON payloads that indicate the
|
|
// Turnstile cookie / nonce has to be re-established. It deliberately matches
|
|
// only explicit markers so a randomly embedded "nonce" string in echoed
|
|
// request data cannot trigger a costly re-solve.
|
|
func isSessionPayload(payload map[string]any) bool {
|
|
var texts []string
|
|
for _, k := range []string{"error", "message", "code"} {
|
|
if v, ok := payload[k]; ok && v != nil {
|
|
texts = append(texts, stringify(v))
|
|
}
|
|
}
|
|
if data, ok := payload["data"].(map[string]any); ok {
|
|
for _, k := range []string{"code", "message", "error"} {
|
|
if v, ok := data[k]; ok && v != nil {
|
|
texts = append(texts, stringify(v))
|
|
}
|
|
}
|
|
}
|
|
if notice, ok := payload["quota_notice"].(map[string]any); ok {
|
|
if v, ok := notice["message"]; ok {
|
|
texts = append(texts, stringify(v))
|
|
}
|
|
}
|
|
text := strings.ToLower(strings.Join(texts, " "))
|
|
for _, m := range []string{
|
|
"deepseek_ts_required",
|
|
"ts_required",
|
|
"nonce_failure",
|
|
"session_expired",
|
|
"forbidden",
|
|
"unauthorized",
|
|
"csrf",
|
|
"verification_required",
|
|
"sicherheitspruefung",
|
|
} {
|
|
if strings.Contains(text, m) {
|
|
return true
|
|
}
|
|
}
|
|
if v, ok := payload["ts_required"]; ok {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case string:
|
|
return strings.TrimSpace(t) != "" && t != "0" && !strings.EqualFold(t, "false")
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isConfigProblem reports errors that retrying cannot fix: the site stopped
|
|
// serving the AIPKit chat page (it now answers with a redirect or HTML), or
|
|
// the config points at something that does not exist.
|
|
func isConfigProblem(err error) bool {
|
|
return strings.Contains(strings.ToLower(err.Error()), "nonce fetch failed")
|
|
}
|
|
|
|
// isCacheEmptyProblem matches the site's cache-message rejection that a
|
|
// dropped config or a cookie re-solve never fixes on the same site: the
|
|
// mirrors carry independent sessions, so callers fail over instead of
|
|
// burning refresh cycles.
|
|
func isCacheEmptyProblem(err error) bool {
|
|
return strings.Contains(err.Error(), "empty_data_to_cache")
|
|
}
|
|
|
|
// isQuotaPayload mirrors the German/English quota notices used upstream.
|
|
func isQuotaPayload(payload map[string]any) bool {
|
|
if _, ok := payload["quota_notice"]; ok {
|
|
return true
|
|
}
|
|
var texts []string
|
|
for _, k := range []string{"error", "message"} {
|
|
if v, ok := payload[k]; ok && v != nil {
|
|
texts = append(texts, stringify(v))
|
|
}
|
|
}
|
|
if notice, ok := payload["quota_notice"].(map[string]any); ok {
|
|
if v, ok := notice["message"]; ok {
|
|
texts = append(texts, stringify(v))
|
|
}
|
|
}
|
|
text := strings.ToLower(strings.Join(texts, " "))
|
|
if strings.Contains(text, "guthaben") || strings.Contains(text, "quota") {
|
|
return true
|
|
}
|
|
return strings.Contains(text, "token") && strings.Contains(text, "aufgebraucht")
|
|
}
|
|
|
|
func stringify(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return fmt.Sprintf("%v", t)
|
|
case bool:
|
|
return fmt.Sprintf("%v", t)
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|