File size: 94,893 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 | // Package upstream 封装对 CodeBuddy 上游(chat / billing / auth)的全部 HTTP 调用,
// 以及错误分类(驱动 pool 冷却状态机)。
package upstream
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
)
// ErrKind 错误分类,pool 据此决定冷却时长。
type ErrKind int
const (
ErrNone ErrKind = iota // 成功
ErrHardCredit // 余额不足(402 或 body 关键词)→ 长冷却
ErrSoftRate // 429 软限流 → 短冷却
ErrSessionDead // 401 + 12153 offline session 失效 → 禁用
ErrNotFound // 404 上游偶发 → 短冷却,不累计错误计数(防雪崩)
ErrServer // 5xx 上游故障
ErrContentBlocked // 内容策略拦截(400 + 审核文案)→ 不罚账号,走降级重试
ErrBadParams // 请求体解析失败(400 + Unmarshal chat params failed / 11101)→ 请求级错误:不罚号、不轮转,末端 400 透传原文
ErrAccountFault // 账号级授权/配额故障(11140 request illegal / 14017 trial not activated)→ 冷却轮换,不无限重试
ErrModelBlocked // 11102「该后端无此模型」→ (账号,模型) 负缓存避让,切模型/切账号
ErrWafBlock // 403 + 非业务信封体(APISIX WAF 拦截页/空体)→ 账号软冷却 + 抖动退避
ErrPromptTooLong // 11115「prompt is too long」→ 请求级错误(上下文超限是请求的问题非账号的问题):不罚号、不轮转,末端透传原文
ErrImageInvalid // 图片请求格式/数据无效 → 请求级错误:不罚号、不轮转,末端透传原文
ErrClient // 其他 4xx / 业务错误
)
func (k ErrKind) String() string {
switch k {
case ErrHardCredit:
return "hard_credit"
case ErrSoftRate:
return "soft_rate"
case ErrSessionDead:
return "session_dead"
case ErrNotFound:
return "not_found"
case ErrServer:
return "server"
case ErrContentBlocked:
return "content_blocked"
case ErrBadParams:
return "bad_params"
case ErrModelBlocked:
return "model_blocked"
case ErrWafBlock:
return "waf_block"
case ErrPromptTooLong:
return "prompt_too_long"
case ErrImageInvalid:
return "image_invalid"
case ErrAccountFault:
return "account_fault"
case ErrClient:
return "client"
default:
return "none"
}
}
// Error 带分类的上游错误。
type Error struct {
Kind ErrKind
Status int
Msg string
// RetryAfter 上游明示的等待时长(Retry-After 秒 / retry-after-ms /
// x-ratelimit-reset 头解析,见 ParseRetryAfter)。零值 = 上游未明示,
// 冷却时长回落调用方计算值。挂载点选在 Error 信封:Kind 决定「罚不罚」,
// RetryAfter 决定「罚多久」,同为上游响应的一等公民。
RetryAfter time.Duration
}
func (e *Error) Error() string {
return fmt.Sprintf("upstream %s (http %d): %s", e.Kind, e.Status, e.Msg)
}
// hardMarkers 余额不足关键词(小写比较 + 中文原文比较双通道)。
var hardMarkers = []string{
"insufficient credit", "no credit", "credit exhausted", "credits exhausted", "out of credit",
"quota exceeded", "quota exhaust", "payment required", "credit not enough",
"not enough credit",
"积分不足", "额度不足", "余额不足", "积分用完", "额度用尽", "没有积分",
}
// softRateMarkers 限流/节流关键词(小写比较 + 中文原文比较双通道)。
// 上游在状态码非 429 时也会返回限流语义(如 200 + code 11140
// "The model provider is rate-limiting requests."、400 + "rate limit"),
// 此类响应若不识别,账号既不被冷却也不喂熔断,下次请求仍会被选中(issue #28)。
//
// 词表按子串匹配,宁缺毋滥:只收录明确指向「请求速率/模型用量被节流」的措辞。
// 连字符形式(rate-limiting / rate-limited)需单列——Contains 不跨 '-'。
// "too many" 会命中 "too many tokens" 这类客户端参数错误,代价是该号被软冷却
// 一个 SoftCooldown(默认 60s)后自愈,远小于漏判限流导致反复选中同一号的代价。
var softRateMarkers = []string{
"rate limit", // rate limit / rate limits / rate limiting
"rate-limiting",
"rate-limited",
"too many requests",
"too many",
"usage limit", // usage limit reached / model usage limit exceeded(用量节流,非计费余额)
"请求过于频繁", "限流",
}
var sessionDeadMarkers = []string{"Offline user session not found", "12153"}
// accountFaultMarkers 账号级授权/配额故障关键词(大小写不敏感子串匹配)。
//
// 定位:这类错误是**账号本身状态**决定的本机故障,不是请求格式、不是临时限流、
// 也不是内容误报——继续重试只会反复刷上游风控/配额检查,必须把该账号冷却轮换。
// - "request illegal"(code 11140)→ 上游 auth/auth_forbidden,账号级授权风控,
// 需重新 OAuth 登录才能恢复,短冷却只能阻止继续送死。
// - code 14017("trial not activated" / "The trial version is not yet activated")→
// 上游 quota/quota_not_activated,register 未完成的试用未激活账号,同样账号级。
//
// 注意 11140 **不能**按 code 判定:该 code 也承载模型级限流文案("The model provider
// is rate-limiting requests."),那种场景必须保持 ErrSoftRate(下方 softRateMarkers
// 后判定)。故此处只收 msg 关键词 "request illegal"(auth_forbidden 的真实文案)。
// 14017 文案唯一(无软限流歧义),可安全收录。
var accountFaultMarkers = []string{
"request illegal",
"trial not activated",
"trial version is not yet activated",
}
// contentBlockedMarkers 内容策略拦截关键词(大小写不敏感子串匹配)。
//
// 定位:上游按逐字精确指纹审核,system 来源的模板句(如 Claude Code/Codex
// 注入指令)触发 HTTP 400 + 以下文案。这是「误报」(合法流量被审核误杀),
// 非账号问题——该账号余额健康、未限流、session 未死,故 ErrContentBlocked
// 在 applyErrorPolicy 中不罚账号(无冷却/熔断/NoteError),改由网关降级重试。
var contentBlockedMarkers = []string{
"blocked by security policy",
"unapproved channel",
"illegal api invocation",
}
// badParamsMarkers 请求体解析失败关键词(issue #41 连带):HTTP 400 + 上游
// "Unmarshal chat params failed..."(code 11101)。这是"发给上游的 body 有问题",
// 与账号健康无关——不罚号,但仍轮转(commit B)。
var badParamsMarkerMsg = "Unmarshal chat params failed"
// invalidImageMarkers 图片请求格式/数据无效(HTTP 400)的**文案**形态。这类错误由
// 请求内容决定,不是账号问题:换账号不会改变同一 body 的解析结果。上游常见形态包括
// `Parse message failed: invalid image_url content`、invalid_image_data、
// `replace the image`。
//
// 业务码 11135 不放在这里:code 判定必须容忍 JSON 空白(`"code": 11135`),
// 字面量 marker 只能覆盖紧凑形态,故统一走 codeMarker(见 Classify 的 400 分支,
// 与 hint.go 的 isInvalidImageData 同口径;上游 5d5223d 的 Copilot review 修复)。
var invalidImageMarkers = []string{
"invalid image_url content",
"invalid_image_data",
"replace the image",
}
// 定位:上下文超限是**请求的问题不是账号的问题**——同一个 body 换任何账号发都会
// 超限,与 WAF fail-fast 同哲学(确定与账号无关的错误不罚号不轮转,白白浪费健康号
// 的请求配额)。marker 双通道:
// - `"code":11115`:业务信封 code 字段(JSON 空格容差;`"code":"11115"` 字符串
// 形态也命中);
// - "prompt is too long":msg 文案(大小写不敏感)。
//
// 只在 400/404/413 请求级状态码上判(429+11115 概率极低且属限流语义优先,
// 5xx 属服务端故障优先)。误判代价(好 body 被归 prompt_too_long):不罚号 +
// 不轮转 + 透传原文,客户端看到上游原文可自行排查,代价可控。
var promptTooLongMarkers = []string{
`"code":11115`,
`"code": 11115`,
`"code":"11115"`,
"prompt is too long",
}
// isPromptTooLongStatus 11115 只在请求级 4xx 上判(见 promptTooLongMarkers 注释)。
func isPromptTooLongStatus(status int) bool {
return status == http.StatusBadRequest || status == http.StatusNotFound ||
status == http.StatusRequestEntityTooLarge
}
// alreadyCheckinMarkers "今天已签到"关键词(上游对重复签到返回 code!=0,
// 实测 code=10001/14001 "今天已签到"/"今日已签到")。只对 *Error.Msg 做包含匹配,
// 网络层/解析层错误不在此识别(见 IsAlreadyCheckin)。
var alreadyCheckinMarkers = []string{"已签到", "already"}
var badParamsMarkerCode = `"code":11101`
// softRateResetLoc 上游 429 6004 文案中的重置时间固定按 UTC+8 解释(上游文案如此,
// 与容器时区无关)。
var softRateResetLoc = time.FixedZone("UTC+8", 8*60*60)
// SoftRateResetLoc 暴露重置时间的固定时区(供测试构造/断言同一时区口径)。
func SoftRateResetLoc() *time.Location { return softRateResetLoc }
// modelRateLimitCode 明确指向「模型级 429 限流」的业务 code。
// 上游用它表达"该模型的使用量超限"(code 6004,msg 带「将在 … 重置」),
// 而不是账号整体被限流——账号健康,只是这个模型此刻被限(issue #31)。
const modelRateLimitCode = "6004"
// softRateResetPatternCN/EN 匹配重置文案(CN「将在 … 重置」/ global 域英文
// "reset at <固定格式时间>"),捕获中间的时间串。
const softRateResetPatternCN = `将在 (.+?) 重置`
const softRateResetPatternEN = `(?i)reset at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`
// 限流判定正则预编译为包级 var:IsModelRateLimit / ParseRateReset 在每次错误
// 分类、每个限流 body 上调用,函数体内 MustCompile 是纯浪费;错误风暴(429
// 轰炸)时尤甚。模式串均为纯常量。regexp 并发安全(匹配只读),无需额外锁。
var (
reModelRateLimit = regexp.MustCompile(`"code"\s*:\s*"?` + modelRateLimitCode + `"?`)
reSoftRateResetCN = regexp.MustCompile(softRateResetPatternCN)
reSoftRateResetEN = regexp.MustCompile(softRateResetPatternEN)
)
// softRateTimeLayout 上游重置时间的格式(无时区后缀;时区固定 UTC+8)。
const softRateTimeLayout = "2006-01-02 15:04:05"
// IsModelRateLimit 报告 429 body 是否明确指向模型级限流(业务 code 6004)。
// 用于区分"账号级软限流"(按账号冷却)与"模型级用量限流"(切模型即可用)。
func IsModelRateLimit(body string) bool {
// `"code":6004` / `"code": 6004` / `"code":"6004"` 均可命中(JSON 空格容差)。
return reModelRateLimit.MatchString(body)
}
// modelBlockCode 明确指向「该后端无此模型」的业务 code。
const modelBlockCode = "11102"
// modelBlockMsgMarker 11102 答复的确定性文案(官方 error message 固定短语)。
// 只收这个窄短语,不收 "model ... not found" 宽正则——后者会误伤其他业务的
// not found 措辞。
const modelBlockMsgMarker = "service info not found"
// ModelBlockReason 11102 负缓存条目在 pool.modelCooldowns 里的 reason 前缀。
// handler 写 BlockModelBackoff;pool.BlockModelClear 按 "11102" 前缀识别条目
// (与 6004 条目的 "6004 model rate limit" reason 互不干扰)。
const ModelBlockReason = "11102 model not available"
// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 的确定性答复。
//
// 只比对 code/msg 等独立字段,绝不做整段文本子串匹配:错误体还带 requestId 等字段,
// 拿整段文本匹配会把 "11102" 撞在 ID 上、误避让一个本来能用的模型。判定 =
// code 字段精确等于 "11102",或 msg/message 字段命中窄短语 "service info not
// found"(两者任一命中即真)。只看 400/404:429 带 11102 属限流语义。
// 字段遍历覆盖顶层与 error 子对象两层。
func IsModelBlocked(status int, body string) bool {
if (status != http.StatusBadRequest && status != http.StatusNotFound) || body == "" {
return false
}
// 轻量预检:body 既无 "11102" 又无 marker 时直接短路(大多数 4xx 零分配返回)。
if !strings.Contains(body, modelBlockCode) && !strings.Contains(strings.ToLower(body), modelBlockMsgMarker) {
return false
}
var root map[string]any
if err := json.Unmarshal([]byte(body), &root); err != nil {
return false
}
nodes := []map[string]any{root}
if inner, ok := root["error"].(map[string]any); ok {
nodes = append(nodes, inner)
}
code, msg := "", ""
for _, node := range nodes {
for _, key := range []string{"code", "errCode", "error_code"} {
if v, ok := node[key]; ok && v != nil && code == "" {
code = strings.TrimSpace(fmt.Sprint(v))
}
}
for _, key := range []string{"msg", "message"} {
if v, ok := node[key].(string); ok && v != "" && msg == "" {
msg = strings.TrimSpace(v)
}
}
}
if code == modelBlockCode {
return true
}
return strings.Contains(strings.ToLower(msg), modelBlockMsgMarker)
}
// hasBusinessCode reports whether a JSON error envelope contains an exact
// business code in a field named "code". Upstream envelopes vary between
// top-level and nested error/data objects, so walk the decoded structure.
func hasBusinessCode(body, want string) bool {
var root any
if err := json.Unmarshal([]byte(body), &root); err != nil {
return false
}
var walk func(any) bool
walk = func(value any) bool {
switch node := value.(type) {
case map[string]any:
if code, ok := node["code"]; ok && strings.TrimSpace(fmt.Sprint(code)) == want {
return true
}
for _, child := range node {
if walk(child) {
return true
}
}
case []any:
for _, child := range node {
if walk(child) {
return true
}
}
}
return false
}
return walk(root)
}
// hasBusinessEnvelope 报告错误 body 是否携带上游业务信封形态(JSON 且含
// `"code":` 或 `"msg":` 字段)。WAF 403 判定(IsWafBlocked)用「无业务信封」
// 区分 APISIX WAF 拦截页(HTML/空体/纯文本)与上游业务层 403(带 code/msg
// 信封,正常走既有分类)。JSON 解析不做:信封存在性只需字段名命中——
// 畸形 JSON 但含 `"msg":` 字样仍按业务响应保守处理(宁漏判 WAF 也不误罚
// 业务 403,后者有各自的权威分类)。
func hasBusinessEnvelope(body string) bool {
return strings.Contains(body, `"code":`) || strings.Contains(body, `"msg":`)
}
// IsWafBlocked 报告 403 响应是否为 WAF 拦截形态:HTTP 403 且 body 无业务信封
// (无 `"code":`/`"msg":` JSON 字段——HTML 拦截页、空体、纯文本均命中)。
// 带业务信封的 403(11140 request illegal / 11128 等)仍走既有分类链。
// 403 含 accountFault 文案的维持现状(ErrAccountFault),由 Classify 的规则序保证。
func IsWafBlocked(status int, body string) bool {
return status == http.StatusForbidden && !hasBusinessEnvelope(body)
}
// retryAfterHeaderCandidates 冷却时长优先解析的响应头候选序列:
// retry-after(秒,RFC 7231)/ retry-after-ms(毫秒)/ x-ratelimit-reset
// (epoch 秒或毫秒,取 now+ 剩余量)。大小写不敏感(http.Header.Get 已归一)。
var retryAfterHeaderCandidates = []string{"Retry-After", "Retry-After-Ms", "X-Ratelimit-Reset"}
// retryAfterSanity 解析结果的上限(超过视为上游异常值丢弃,回落本地计算),
// 与 pool 的 softRateMax 默认 2h 同量级。
const retryAfterSanity = 2 * time.Hour
// ParseRetryAfter 从限流/拦截响应头解析上游明示的等待时长:
// 依次尝试 Retry-After(整数秒)→ retry-after-ms(整数毫秒)→
// x-ratelimit-reset(纯数字按 epoch 秒/毫秒推断;HTTP-Date 形态不支持——
// 上游族实践发的是数字)。任一头缺失/非法/非正/超上限则尝试下一头;
// 全部不可用返回 false(调用方回落既有计算值,绝不臆造等待时长)。
func ParseRetryAfter(h http.Header) (time.Duration, bool) {
for _, name := range retryAfterHeaderCandidates {
v := strings.TrimSpace(h.Get(name))
if v == "" {
continue
}
if !isAllDigits(v) {
continue // 非纯数字(如 HTTP-Date)不解析,宁缺毋滥
}
n, ok := parseRetryNumber(v, name)
if !ok {
continue
}
if n <= 0 || n > retryAfterSanity {
continue // 非正/异常大:丢弃(回落本地计算)
}
return n, true
}
return 0, false
}
// isAllDigits 报告 s 是否为纯数字(前置快筛,免 strconv 之后再判语义)。
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// parseRetryNumber 按头名口径把纯数字串折算成时长。x-ratelimit-reset 是
// epoch 时刻而非时长:秒口径(10 位)与毫秒口径(13 位)都按「now+ 该时刻
// 的剩余量」折算,已在过去则不可用。位数不足(8 位以下)无法判定 epoch
// 语义的丢弃(宁缺毋滥:短串多半是序号之类的误用头)。
func parseRetryNumber(v, headerName string) (time.Duration, bool) {
// 上限 16 位防 int64 溢出(超过 epoch 毫秒的现实量级必非法)。
if len(v) > 16 {
return 0, false
}
var n int64
for _, r := range v {
n = n*10 + int64(r-'0')
}
switch headerName {
case "Retry-After":
// 先做上限校验再乘 time.Second:16 位数字乘 1e9 会溢出 int64 回绕成
// 小正数,进而通过调用方的 retryAfterSanity 校验被当作合法等待时长。
if n > int64(retryAfterSanity/time.Second) {
return 0, false
}
return time.Duration(n) * time.Second, true
case "Retry-After-Ms":
if n > int64(retryAfterSanity/time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
default: // X-Ratelimit-Reset:epoch → 剩余量
sec := n
if len(v) >= 12 { // 毫秒口径(13 位);11 位边界按秒(误判代价是多算 1000 倍)
sec = n / 1000
}
remain := time.Until(time.Unix(sec, 0))
return remain, true
}
}
// ParseRateReset 从任何限流响应 body 里统一解析「将在 … 重置」时间(上游 UTC+8 文案)。
// 成功返回解析出的**墙钟时刻**(按 UTC+8 解释),失败返回零值 + false。
//
// 是否走模型级豁免、时日对齐到 until 还是 modelCooldowns,由冷却决策侧(pool)按
// IsModelRateLimit 判定,本函数只负责「把上游明说的恢复时刻抽出来」。没有时间文案
// 的限流也照常由调用方退回有界退避(绝不臆造时间)。
func ParseRateReset(body string) (time.Time, bool) {
// CN 文案优先;global 域 429 body 是英文形态("will reset at YYYY-MM-DD HH:MM:SS
// UTC+8"),此前只认中文 → global 限流解析不到恢复时刻,退回有界退避基数反复
// 翻倍(修「global 域冷却指数翻倍」)。英文正则锚定固定格式时间,自然语言
// ("reset at the end of the day")不匹配。
m := reSoftRateResetCN.FindStringSubmatch(body)
if len(m) < 2 {
m = reSoftRateResetEN.FindStringSubmatch(body)
}
if len(m) < 2 {
return time.Time{}, false
}
ts := strings.TrimSpace(m[1])
ts = strings.TrimSuffix(ts, " UTC+8") // 去掉后缀,固定按 softRateResetLoc 解释
t, err := time.ParseInLocation(softRateTimeLayout, ts, softRateResetLoc)
if err != nil {
return time.Time{}, false
}
return t, true
}
// Classify 按 HTTP 状态码 + body 判定错误类别。
//
// 判定顺序自「严」到「宽」,每层的先后都有语义依据:
// 0. 11102(IsModelBlocked)——「该后端无此模型」确定性答复,语义最具体,最先判
// (只认 400/404,429+11102 属限流语义走第 4 层)。
// 1. 402 —— 真正的计费余额耗尽状态码,最严、最不可自愈,最先判。
// 2. sessionDeadMarkers —— 需要人工重登的终态。若 401 body 同时含 "12153" 与
// "rate limit"(如网关错误页混排),归 session_dead:短冷却救不活失效 session,
// 误判为限流会让该死号留在池中反复被选中;且此层 marker 是精确词(12153 等),
// 比限流层的大范围子串更具体,具体优先于宽泛。
// 3. accountFaultMarkers —— 账号级授权/配额故障(11140 request illegal auth 风控、
// 14017 trial not activated register 未完成)。必须先于 status==429 判定:
// 14017 常带 429 状态码,若落到 status==429 会误归 soft_rate("限流"语义不符:
// 限流可指数退避等自愈,账号级故障等不来)。11140 的 model 级限流变体
// (rate-limiting 文案)因 marker 不含该文案而天然落到 softRateMarkers 层,
// 不受影响。
// 4. 429 + code 14018 —— 明确的账号积分耗尽,归 ErrHardCredit(issue #175)。
// 只认结构化业务码,不靠可能跨计费/限流两界的文案猜测。
// 5. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带
// "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把
// 限流误归 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词
// 更权威的信号;真正的余额耗尽由 402(第 1 层)或 14018(第 4 层)捕获,
// 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 6 层)。
// 6. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。
// 7. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。
// 位于此处可覆盖 200/400/403/5xx 各状态码。
// 8. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底
// 之前(404 上打 11115 若落 ErrNotFound 会误冷却账号——上下文超限与账号无关)。
// 9. 404 / 5xx —— 与限流无关的常规分类。
// 10. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF
// 拦截形态。判在通用 4xx 兜底**之前**:此前该形态落 ErrClient → 只换号不罚 →
// 连环 403。带业务信封的 403 已被上方各层捕获,走不到本层。
// 11. 内容策略/参数错误/其他 4xx —— 通用兜底。
func Classify(status int, body string) ErrKind {
// 11102「该后端无此模型」须最先判:它是「模型在后端不存在」的确定性答复,语义比
// 计费/限流都更具体——若不先判,msg 里的 "service info not found" 会被更宽的
// 4xx 兜底归为 ErrClient(只换号不避让),该坏号会留在池内反复被选中。
// 只认 400/404(见 IsModelBlocked),429+11102 落下方 status==429 层走限流语义。
if IsModelBlocked(status, body) {
return ErrModelBlocked
}
// 402:真正的计费余额耗尽状态码,最严、最不可自愈,最先判。
if status == http.StatusPaymentRequired {
return ErrHardCredit
}
lower := strings.ToLower(body)
// sessionDead / accountFault 先于 status==429:账号级终态等不来自愈,限流状态码
// 不得掩盖它们(429+14017 必须 accountFault,401+12153 混排 "rate limit" 必须
// sessionDead——此层 marker 是精确词,比限流层的大范围子串更具体,具体优先于宽泛)。
for _, m := range sessionDeadMarkers {
if strings.Contains(body, m) {
return ErrSessionDead
}
}
for _, m := range accountFaultMarkers {
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
return ErrAccountFault
}
}
// 14018 是明确的账号积分耗尽业务码。它必须先于通用 429 兜底,否则会被误判为
// 可自愈的软限流并在全池冷却时反复兜底选中(issue #175)。仅按结构化 code
// 判定;无该 code 的 "credits exhausted" 文案仍保持普通 429 的软限流语义。
if status == http.StatusTooManyRequests && hasBusinessCode(body, "14018") {
return ErrHardCredit
}
// status==429 先于 hardMarkers:限流响应 body 高频携带 "quota exceeded"/
// "额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把限流误归
// ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词更权威的
// 信号:上游既然给了 429,就按限流语义处理(宁可短冷却自愈,不可长冷却弃号);
// 真正的余额耗尽由 402(上层)或 14018(上层)捕获,非 429 状态码的 quota
// 措辞仍走下方 hardMarkers(历史语义不变)。
if status == http.StatusTooManyRequests {
return ErrSoftRate
}
for _, m := range hardMarkers {
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
return ErrHardCredit
}
}
for _, m := range softRateMarkers {
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
return ErrSoftRate
}
}
// 11115「prompt is too long」:判在 404/5xx/WAF/内容策略/参数错误/通用 4xx
// 之前——请求级语义最具体(上下文超限),须先于宽泛的状态码兜底(404 兜底会
// 误归 ErrNotFound 只冷却不透传;ErrClient 只换号,浪费健康号配额)。
if isPromptTooLongStatus(status) {
for _, m := range promptTooLongMarkers {
if strings.Contains(body, m) || (m != strings.ToLower(m) && strings.Contains(lower, strings.ToLower(m))) {
return ErrPromptTooLong
}
}
}
if status == http.StatusNotFound {
return ErrNotFound
}
if status >= 500 {
return ErrServer
}
// WAF 403(无业务信封的拦截形态):判在内容策略/参数错误/通用 4xx 之前——
// 这些层只认带文案的 body,WAF 空体/HTML 永远不会命中它们的 marker,
// 但落 ErrClient 兜底的代价是「只换号不罚」(连环 403 根因),必须在兜底前分流。
// 带信封的 403 在上方各层已有权威分类,不受影响。
if IsWafBlocked(status, body) {
return ErrWafBlock
}
// 图片格式/数据错误是确定性的请求级错误:同 body 换账号结果不变,直接
// fail-fast,避免把健康账号轮转一遍后仍把最终 503 返回给客户端。
// 11135 业务码走 codeMarker(JSON 空白容差),文案走 invalidImageMarkers。
if status == http.StatusBadRequest && codeMarker(lower, "11135") {
return ErrImageInvalid
}
if status == http.StatusBadRequest {
for _, m := range invalidImageMarkers {
if strings.Contains(lower, m) {
return ErrImageInvalid
}
}
}
// 内容策略拦截(HTTP 400 + 审核文案):判在通用 ErrClient 之前。
// 这是误报信号,不罚账号,由网关降级重试处理(见 handler.applyErrorPolicy)。
if status >= 400 {
for _, m := range contentBlockedMarkers {
if strings.Contains(lower, m) {
return ErrContentBlocked
}
}
// 请求体解析失败(HTTP 400 + Unmarshal chat params failed / code 11101):
// 这是"发给上游的 body 有问题"。网关侧截断已由 413 消灭(issue #41 commit A),
// 剩余来源是客户端 JSON 本身畸形——换了账号照样 400,不该罚号(白白冷却好号)。
// 归 ErrBadParams:不冷却/不熔断/不计错,且**不轮转**——11101 发生在上游解析
// 请求体阶段,还没走到模型路由,所以"不同账号可能有不同模型权限"其实是
// 11102(ErrModelBlocked)的理由,那里已有 (账号,模型) 负缓存避让。
if strings.Contains(body, badParamsMarkerMsg) || strings.Contains(body, badParamsMarkerCode) {
return ErrBadParams
}
return ErrClient
}
// HTTP 200 但业务 code 非 0 且含余额关键词的情况已被上面 hardMarkers 捕获。
return ErrNone
}
// apiEnvelope 上游统一信封。
type apiEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// Client 上游 HTTP 客户端。Base 字段可覆盖便于测试。
type Client struct {
HTTP *http.Client
// ChatHTTP 聊天 SSE 专用 client:无总时长上限(Timeout=0),首字节由
// Transport.ResponseHeaderTimeout 约束,流中空闲由 IdleTimeout 约束。
// 与 HTTP 共享同一个 *http.Transport 实例,连接池不重复。
ChatHTTP *http.Client
// HeaderTimeout 聊天 SSE 首字节前(响应头)超时;<=0 表示未设置(回落 HTTP.Timeout)。
HeaderTimeout time.Duration
// IdleTimeout 聊天 SSE 流中空闲超时;<=0 表示禁用空闲监控。
IdleTimeout time.Duration
// effortsMu/efforts 缓存各模型 supportedEfforts(FetchModels 刷新),供请求体 effort 降级。
// 按 realm 分层桶(cn/global):同模型名跨域探测的 effort 集合可能不同,
// 混桶会互相污染(C-2)。
effortsMu sync.RWMutex
efforts map[string]map[string][]string
// defaultEfforts 缓存各模型 reasoning.defaultEffort(FetchModels 刷新),供
// thinking.go 补档:缺显式 effort 时优先用模型声明默认档,空串回退硬编码 high。
// 与 efforts 同 realm 分层桶(同 C-2 隔离原则),共用 effortsMu。
defaultEfforts map[string]map[string]string
// modelRates 缓存各模型当前生效积分倍率(规范化数值,如 "0.5")。
// 与 efforts 共用 realm 分层和锁;每次成功刷新模型目录时整体替换对应域。
modelRates map[string]map[string]string
// globalModels 缓存 global 模型名目录探测结果(成功 ∩ 静态 overlay;
// 1h TTL + 5min 负缓存),见 global_models.go。按实例持有,测试新建 Client 即隔离。
globalModels fetchGlobalModelsCache
// SanitizeFingerprints 出站请求体黑名单指纹脱敏开关(默认 true;false 完全还原)。
// 面板保存配置热改 + chat 热路径并发读写,用 atomic.Bool 消除数据竞争。
SanitizeFingerprints atomic.Bool
// UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。
// 空 = 默认官方形态:chat/refresh/FetchModels 走
// `WorkBuddy/<ver> WorkBuddy/<ver> CLI/<cliVer>`;billing 走 `WorkBuddy/<ver>`
// (仅当 client_name 非空)。
UserAgent string
// ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` + X-IDE-Version)。
// 空 = 内置默认(对齐官方 5.5.4 分发包)。
ClientVersion string
// CliVersion 出站 UA 中 `CLI/<ver>` 段版本。空 = 内置默认(官方内置 CLI 2.137.1)。
CliVersion string
// ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。
// 空 = 旧行为:X-Product="SaaS",不设 X-IDE-*(向后兼容,不突变归因)。
ClientName string
// PassthroughIP 是否透传客户端 IP 给上游(X-Forwarded-For/X-Real-IP 首段)。
// 缺省 false(反代安全边界);handler 在 chat 路径按请求把 clientIP 传入 ChatStream。
PassthroughIP bool
// DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底来源:config upstream.device_token。
// 解析优先级:auth.Auth.DeviceToken > DeviceToken(config)> DeviceTokenFile(文件)。
DeviceToken string
// DeviceTokenFile 设备 token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。
DeviceTokenFile string
ChatBaseCN string
BillingBaseCN string
// WebBaseCN 官网(workbuddy.cn)域:部分「任务领奖」类接口只在此域提供
// (Web 成长中心用;CLI 域 copilot.tencent.com 的同名路径返回 400)。
WebBaseCN string
// ChatBaseGlobal / BillingBaseGlobal 国际版(global realm)上游 base。
// 空 = 缺省默认 https://www.workbuddy.ai(D5)。
ChatBaseGlobal string
BillingBaseGlobal string
// GlobalEnabled 是否启用 global realm 路由(config global.enabled,缺省 true)。
// false 时即便用户 auth 写了 realm=global 也**不**路由到 global base——
// chatBase/billingBase 返回 CN base,路径也走 CN(双保险,与 auth.Realm() 的开关闸呼应)。
GlobalEnabled bool
}
// New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 /
// TLS 握手超时 / 短 keepalive 探测 / 失败清池,参数见 transport.go——吸收上游
// kongjianguan 4 连击实测经验)。
func New() *Client {
tr := newTransport()
c := &Client{
HTTP: &http.Client{Timeout: 120 * time.Second, Transport: tr},
ChatHTTP: &http.Client{Timeout: 0, Transport: tr}, // 无总时长;首字节由 ResponseHeaderTimeout 管
ChatBaseCN: "https://copilot.tencent.com",
BillingBaseCN: "https://www.codebuddy.cn",
WebBaseCN: "https://www.workbuddy.cn",
// GlobalEnabled 缺省 true(与 config global.enabled 缺省 true 一致;纯 CN 部署行为不变:
// CN 账号恒判 cn,global base 只在 realm=global 的账号上被使用)。
GlobalEnabled: true,
}
c.SanitizeFingerprints.Store(true)
return c
}
// chatHTTP 返回聊天专用 client;未设置(如测试只注入 HTTP)时回落 HTTP。
func (c *Client) chatHTTP() *http.Client {
if c.ChatHTTP != nil {
return c.ChatHTTP
}
return c.HTTP
}
// defaultGlobalBase 缺省 global base(D5:config 未覆盖时默认 workbuddy.ai)。
const defaultGlobalBase = "https://www.workbuddy.ai"
// globalChatBase 生效的 global chat base:Client.ChatBaseGlobal 非空取之,否则默认。
func (c *Client) globalChatBase() string {
if c.ChatBaseGlobal != "" {
return c.ChatBaseGlobal
}
return defaultGlobalBase
}
// globalBillingBase 生效的 global billing base:Client.BillingBaseGlobal 非空取之,否则默认。
func (c *Client) globalBillingBase() string {
if c.BillingBaseGlobal != "" {
return c.BillingBaseGlobal
}
return defaultGlobalBase
}
// globalOn 报告账号是否路由到 global 上游:GlobalEnabled 开且账号 Realm()==global。
// 双保险:config 开关是第一道闸(上游侧),auth.Realm() 的开关闸是第二道(账号侧)。
func (c *Client) globalOn(a *auth.Auth) bool {
return c.GlobalEnabled && a != nil && a.Realm() == "global"
}
// 路径常量:CN 与 global 共用的 chat 出站路径(/v2 单路径)。
const chatCompletionsPath = "/v2/chat/completions"
// chatPaths 返回按 realm 的 chat 路径候选序列:
// global → [/v2](#119 固定单路径:/console 挂腾讯云 WAF body 内容规则,反引号
// printf/whoami 等命令执行特征确定性 403;/v2 同 base 不挂该规则,实测等价端点。
// 已知取舍:若上游未来关闭 /v2,global chat 整体不可用——届时应重新启用 /console
// 路径,此注释即"坏了再说"的锚点);cn → [/v2](单元素,现状)。
func (c *Client) chatPaths(a *auth.Auth) []string {
return []string{chatCompletionsPath}
}
// billing 域端点路径(billingBase + path)。balance/checkin 与 report(report.go)同域,
// 统一走 billingJSON 发请求。
const (
billingMeterPath = "/billing/meter/get-user-resource" // global 首选(国际版无 /v2 前缀)
dailyCheckinPath = "/billing/meter/daily-checkin" // global 首选
billingMeterPathV2 = "/v2/billing/meter/get-user-resource" // CN 现状 / global fallback
dailyCheckinPathV2 = "/v2/billing/meter/daily-checkin"
)
// billingMeterPaths 按 realm 返回 billing/meter 域路径候选序列:
// global → [无 /v2, 有 /v2](404 时 fallback);cn → [有 /v2](现状逐字,零回归)。
// 仅作用于 get-user-resource / daily-checkin(/billing/meter/* 族);report /v2/report 不参与,
// 其他 billing 端点(growth 等)路径不含 /billing/meter 前缀,走原常量不受影响。
func (c *Client) billingMeterPaths(a *auth.Auth) []string {
if c.globalOn(a) {
return []string{billingMeterPath, billingMeterPathV2}
}
return []string{billingMeterPathV2}
}
// checkinMeterPaths 同上,针对 daily-checkin。
func (c *Client) checkinMeterPaths(a *auth.Auth) []string {
if c.globalOn(a) {
return []string{dailyCheckinPath, dailyCheckinPathV2}
}
return []string{dailyCheckinPathV2}
}
func (c *Client) chatBase(a *auth.Auth) string {
if c.globalOn(a) {
return c.globalChatBase()
}
return c.ChatBaseCN
}
// prepareBody 组装出站请求体(脱敏开关由 Client.SanitizeFingerprints 控制)。
// realm 为账号 Realm()(cn/global),供 efforts 缓存分桶(跨域 effort 集合不互相污染)。
func (c *Client) prepareBody(body []byte, realm, uid, conversationID string) []byte {
efforts, defs := c.effortsSnapshot(realm), c.defaultEffortsSnapshot(realm)
if realmKey(realm) == "global" {
// global 域降级源 = 远端探测桶(权威)∪ 产品静态兜底表(全局 21 名内档位如
// deepseek-v4.1-flash ['high'])。当前探测桶为空时也按静态表降级,不全程透传
//(issue #84:往 WorkBuddy 上游发 low/max 非法,须降级到 high)。
efforts, defs = globalEffortMap(efforts, defs)
}
body = PrepareBodyOptWithEffortsAndDefault(body, c.SanitizeFingerprints.Load(), efforts, defs)
// prompt_cache_key 注入(P0 费用优化,费用降 ~17×):按账号隔离的稳定缓存键,
// 让同一客户端对同一账号的连续请求命中上游前缀缓存。
body = InjectPromptCacheKey(body, uid, conversationID)
return body
}
// effortsSnapshot 返回 effort 能力缓存副本;nil 表示未知(透传不降级)。
func (c *Client) effortsSnapshot(realm string) map[string][]string {
c.effortsMu.RLock()
defer c.effortsMu.RUnlock()
bucket, ok := c.efforts[realmKey(realm)]
if !ok || len(bucket) == 0 {
return nil
}
cp := make(map[string][]string, len(bucket))
for k, v := range bucket {
cp[k] = v
}
return cp
}
// defaultEffortsSnapshot 返回指定 realm 的模型 defaultEffort 缓存副本;
// 该域无探测或无声明默认档 → nil(thinking.go 回退硬编码 high)。
func (c *Client) defaultEffortsSnapshot(realm string) map[string]string {
c.effortsMu.RLock()
defer c.effortsMu.RUnlock()
bucket, ok := c.defaultEfforts[realmKey(realm)]
if !ok || len(bucket) == 0 {
return nil
}
cp := make(map[string]string, len(bucket))
for k, v := range bucket {
cp[k] = v
}
return cp
}
// realmKey 归一化 efforts 缓存键:cn/global。空 realm 视为 cn(老调用/无前缀模型名)。
func realmKey(realm string) string {
if realm == "" {
return "cn"
}
return realm
}
func (c *Client) billingBase(a *auth.Auth) string {
if c.globalOn(a) {
return c.globalBillingBase()
}
return c.BillingBaseCN
}
// webBase 返回官网域(任务领奖类接口;未注入时回落默认)。
// realm 感知:global 账号切国际站 workbuddy.ai,CN 用 workbuddy.cn。
func (c *Client) webBase(a *auth.Auth) string {
if c.globalOn(a) {
return defaultGlobalBase
}
if c.WebBaseCN != "" {
return c.WebBaseCN
}
return "https://www.workbuddy.cn"
}
// doJSON 发请求并解信封;HTTP 非 2xx 或业务 code != 0 时返回带 body 片段的 *Error。
// body 读失败(连接中断/空闲掐流/截断)返回普通错误(非 *Error)——半截 body 不进
// Classify,不参与账号惩罚(传输层故障不该喂熔断误罚号)。
func (c *Client) doJSON(req *http.Request) (json.RawMessage, error) {
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= 400 {
kind := Classify(resp.StatusCode, string(raw))
return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)}
}
var env apiEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("parse failed: %w (body: %s)", err, truncate(string(raw), 120))
}
if env.Code != 0 {
kind := Classify(resp.StatusCode, env.Msg)
if kind == ErrNone {
kind = ErrClient
}
return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: fmt.Sprintf("code=%d msg=%s", env.Code, truncate(env.Msg, 160))}
}
return env.Data, nil
}
// RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值),
// 调用方负责 SaveAtomic。全程持 a 锁,防止并发 SaveAtomic 读半更新 token。
// refreshIOTimeout 刷新端点网络 I/O 上限(两段式锁外执行,防上游 hang 长占锁)。
const refreshIOTimeout = 30 * time.Second
// refreshTokenExpiresInMax refresh 响应 expiresIn 的量级上限(10 年,纯防御值:
// 实测 R-D 响应恒 5184000=60d)。超限视为上游脏数据,不写 ExpiresAt(保留旧值),
// 防止 NeedsRefresh 永假导致 token 永不刷新反而真过期失效。
const refreshTokenExpiresInMax = 10 * 365 * 24 * time.Hour
// RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值),
// 调用方负责 SaveAtomic。
//
// 并发安全模型(两段式,缩小持锁窗口):
// - 锁内仅做「读 refreshToken 快照」与「校验未变后写回新 token」两小段内存操作;
// - 网络 I/O(doJSON)在**锁外**执行,带 30s ctx 超时——避免上游 hang 时长时间
// 独占 a.mu,阻塞同账号的 SaveAtomic / 其他刷新(issue:持锁 120s I/O)。
// - 写回前重新校验快照一致性:若锁外期间另一 goroutine 已完成刷新(refreshToken
// 已变),本次结果直接采用(新 token 已生效),不再重复写回。
func (c *Client) RefreshToken(a *auth.Auth) error {
// 第 1 段(锁内):读快照。
a.Lock()
rtSnapshot := a.RefreshToken
atBefore := a.AccessToken
a.Unlock()
if strings.TrimSpace(rtSnapshot) == "" {
return fmt.Errorf("no refreshToken")
}
endpoint := c.chatBase(a) + "/v2/plugin/auth/token/refresh"
ctx, cancel := context.WithTimeout(context.Background(), refreshIOTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
// RefreshHeaders 读取 a 的字段(domain/uid 等)注入请求头——需在锁内取快照值,
// 用一个显式逐字段拷贝的临时 auth 构造头(不拷贝 sync.Mutex,避免 vet copies-lock)。
a.Lock()
hdrSnapshot := auth.Auth{
AccessToken: a.AccessToken,
RefreshToken: rtSnapshot,
ExpiresAt: a.ExpiresAt,
Domain: a.Domain,
UID: a.UID,
EnterpriseID: a.EnterpriseID,
Nickname: a.Nickname,
DeviceToken: a.DeviceToken,
}
a.Unlock()
c.RefreshHeaders(req, &hdrSnapshot)
// 网络 I/O(锁外,30s 上限)。
data, err := c.doJSON(req)
if err != nil {
return err
}
var tok struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int64 `json:"expiresIn"`
Domain string `json:"domain"`
}
if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" {
return fmt.Errorf("refresh_failed: no accessToken in response — re-login required")
}
// 第 2 段(锁内):校验快照一致后写回。
a.Lock()
defer a.Unlock()
// 写回守卫是 AND 语义:锁外期间另一刷新已完成 → 两 token 必同时变化(实测 R-D:
// refresh 响应 accessToken/refreshToken 总是一起 rotate,写回也同时写两个),AND
// 即「并发刷新已完成」判据;AND 与 OR 在真实形态下等价。唯 OR 会额外放弃的
// 「只有单 token 变化」(如手工只改 auth 文件一个字段)不构成放弃条件——本次
// 结果覆盖手工编辑。
if a.AccessToken != atBefore && a.RefreshToken != rtSnapshot {
// 锁外期间另一 goroutine 已完成刷新:新 token 已生效,本次结果不必再写
// (实测 R-E:服务端无 rotation 撤销,并发双刷新拿到的两个新 token 都有效,
// 后写覆盖先写二者等价可用;提前返回避免无意义覆盖与 ExpiresAt 抖动)。
return nil
}
a.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
a.RefreshToken = tok.RefreshToken
}
if tok.Domain != "" {
a.Domain = tok.Domain
}
// preserveExpiry:响应缺 expiresIn 时保留旧过期时间,避免刷新风暴。
// 实测 R-D 响应恒带 expiresIn=5184000(60d)——缺省分支仅为防御,保留旧值
// 避免过期判定漂移。同理,超过 10 年的 expiresIn 按脏值处理保留旧值:
// 实测 JWT exp-iat 与 expiresIn 严格自洽(R-F),超量级值只会是上游脏数据,
// 照写会把 ExpiresAt 推到荒谬未来 → NeedsRefresh 永假 → token 永不刷新
// 反而真过期失效。
if tok.ExpiresIn > 0 && time.Duration(tok.ExpiresIn)*time.Second < refreshTokenExpiresInMax {
a.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix()
}
return nil
}
// ChatStream 发 chat 请求并返回原始 SSE body 流(调用方负责 Close)。
// 等价于 ChatStreamContext(context.Background(), ...):不带调用方取消语义。
// 需要客户端断开联动的调用方用 ChatStreamContext 传入请求 ctx。
//
// global realm:先打 /console/chat/completions,404/405 时同一 base 二次换 /v2/chat/completions
// (上游新旧路径分叉,PLAN R9 fallback 顺序)。cn:/v2/chat/completions 现状不变。
func (c *Client) ChatStream(a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) {
return c.ChatStreamContext(context.Background(), a, body, clientIP, meta)
}
// ChatStreamContext 同 ChatStream,但从 ctx 派生请求 context:调用方(handler)传入
// r.Context() 后,客户端断连/请求取消会立即中断在途上游调用、释放连接与账号在途名额,
// 不再空转到 IdleTimeout。ctx 为 nil 时回落 Background。成功流的 cancel 仍由
// monitorBody 的 Close 接管(reqCtx 取消与显式 Close 任一触发即断)。
//
// 错误路径(≥400 且非 fallback 状态码)除 (status, respBody) 外还返回**已分类的**
// *Error(Kind 信封 + Retry-After 头解析):客户端错误分类在此一次完成,handler
// 不再对 body 二次 Classify(消除「上游分类一次、网关再分类一次」的双路径漂移面),
// Retry-After 也随信封流动。respBody 仍原样返回(错误透传语义:message 透传上游
// 原文)。判定为 ErrNone 的响应(理论上不存在,防御)err 为 nil,handler 按
// respBody 自行兜底。
//
// global chat 自 #119 实测后固定走 /v2(chat 层无 fallback 链;billing 层的 404
// fallback 独立存在,语义不受影响)。ensureConsoleSystem 在 prepareBody 后统一套用
// 全局脚本:首条消息非 system 时前置兜底 system(防 console 域上游 code 11-128;
// #119 后 global 出站固定 /v2,该兜底保留——上游对 /v2 是否需要 system 无实测
// 反证,删了无回滚路径)。
func (c *Client) ChatStreamContext(ctx context.Context, a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) {
if ctx == nil {
ctx = context.Background()
}
prepared := c.prepareBody(body, a.Realm(), a.UID, meta.ConversationID)
if c.globalOn(a) {
prepared = ensureConsoleSystem(prepared)
}
// reqCtx 的 cancel 在每个出口显式调用(Do 失败 / ≥400 / 成功分支移交 monitorBody),
// 循环本身各分支必 return——无循环尾兜底代码(此前外层 var cancel 从未赋值 +
// 尾部不可达 cancel() 是潜伏 nil-panic,已删;chatPaths 恒非空由构造保证)。
for _, path := range c.chatPaths(a) {
endpoint := c.chatBase(a) + path
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(prepared))
if err != nil {
return nil, 0, nil, err
}
c.ChatHeaders(req, a, clientIP, meta)
// 从调用方 ctx 派生:保留取消传播(父 ctx 取消 → 本 ctx 取消),
// 同时 monitorBody.Close 仍能独立 cancel 本分支(空闲掐流)。
reqCtx, cancel := context.WithCancel(ctx)
req = req.WithContext(reqCtx)
resp, err := c.chatHTTP().Do(req)
if err != nil {
cancel()
log.Printf("ERR: [upstream] chat_stream acct=%s: transport error: %v", logfmt.Label(a.UID, a.Nickname), err)
// 传输层失败 → 清空共享连接池的空闲连接(连接层加固):失败连接可能仍
// 留在空闲池里,下一个请求会继续捡到它——仅靠 IdleConnTimeout 等过期
// 不够,主动清池才断根。
roundTripCloseIdle(c.chatHTTP().Transport)
return nil, 0, nil, err
}
if resp.StatusCode >= 400 {
raw, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
cancel()
// body 读失败(掐流/截断)→ 传输层错误:半截 raw 不交回调用方进 Classify,
// 否则 handler 侧 applyErrorPolicy 会按误判分类罚号。
if rerr != nil {
log.Printf("ERR: [upstream] chat_stream acct=%s: read body: %v", logfmt.Label(a.UID, a.Nickname), rerr)
return nil, 0, nil, fmt.Errorf("read body: %w", rerr)
}
kind := Classify(resp.StatusCode, string(raw))
log.Printf("WARN: [upstream] chat_stream acct=%s: upstream %d %s body=%s",
logfmt.Label(a.UID, a.Nickname), resp.StatusCode, kind, truncate(string(raw), 200))
// ≥400 直接返回(#119 后 global 单路径 /v2,chat 层无 fallback 链)。
// 分类一次、随 Kind 信封返回(含 Retry-After 头解析):
// ErrNone 是防御分支(≥400 不应产生 None),返回原文让 handler 兜底。
if kind == ErrNone {
return nil, resp.StatusCode, raw, nil
}
ue := &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)}
if d, ok := ParseRetryAfter(resp.Header); ok {
ue.RetryAfter = d
}
return nil, resp.StatusCode, raw, ue
}
// 成功分支:cancel 所有权交给 monitorBody(其 Close 会 cancel);
// IdleTimeout<=0 时 monitorBody 原样返回底流、无人调 cancel——可接受:
// 取消传播由 http.Transport 在 body Close / 父 ctx 取消时处理,连接正常清理。
return monitorBody(resp.Body, c.IdleTimeout, cancel), resp.StatusCode, nil, nil
}
panic("unreachable: chatPaths is never empty") // for range 空集时编译器仍要求兜底 return;chatPaths 恒非空(构造保证),永不触达
}
// ModelInfo 动态模型信息(含 maxInputTokens/maxOutputTokens + 上游模型对象全字段)。
// CN /console 与 global /v2 的模型对象同构,故共用此结构;上游省略的字段保持零值,
// /v1/models 侧按「空值省略」透出(不编造)。
type ModelInfo struct {
ID string
Name string
ContextWindow int64 // = maxInputTokens
MaxTokens int64 // = maxOutputTokens(思考与最终回答共享此预算,上游无独立思考上限字段)
Efforts []string // reasoning.supportedEfforts(空=未知/固定档)
DefaultEffort string // reasoning.defaultEffort(新模型键)或 reasoning.effort(老模型键);空=未返回
// 模型目录全字段(models-full-fields):
Description string // descriptionZh 中文描述
Credits string // credits 积分倍率原文(如 "x0.05"),仅展示不参与选号
Tags []string // tags 模型标签(含 badge:限时免费 等)
Vendor string // vendor 厂商标识
IsDefault bool // isDefault 是否默认模型
SupportsReasoning bool // supportsReasoning 是否支持推理
SupportsToolCall bool // supportsToolCall 是否支持工具调用
OnlyReasoning bool // onlyReasoning 是否纯推理模型
SupportsImages bool // 顶层 supportsImages(多模态能力,透出到 /v1/models)
MaxAllowedSize int64 // maxAllowedSize 最大允许上下文(与 maxInputTokens 口径并列,上游各自下发)
CanDisableThinking bool // reasoning.canDisableThinking:思考可关(off 档可用)
ReasoningEffort string // reasoning.effort 推理模式(与 supportedEfforts 数组不同源)
ReasoningSummary string // reasoning.summary 推理摘要模式(如 "auto")
// 优惠(modelPromotions,/v3/config data.modelPromotions):Credits 是**牌价**
//(转正后基准倍率),Promo* 是当前生效的限时优惠——面板据此显示「生效价 +
// 标签 + 牌价」。PromoFactor 为 nil 表示无 machine-readable 折扣(如「错峰
// 使用」只有时段文案无 factor),仅挂标签/提示。
PromoFactor *float64 // 折扣系数(0=限时免费,0.5=五折);nil=无
PromoCredits string // 折扣后倍率原文(如 "0x" / "0.50x"),仅展示
PromoLabel string // 徽章文案(限时免费 / 夜间折扣 / 错峰使用)
PromoNote string // hover 说明原文(含时段/日期描述)
}
// dynModelEntry 上游模型目录(CN /console 与 global /v2 同构)的单条模型解析形态,
// FetchModels 与 global_models.go 的探测共用。iconUrl/descriptionEn/生成参数等
// 按「不透出」原则不解析。modelInfo() 是 dynEntry→ModelInfo 映射的单一事实来源,
// 杜绝两域映射漂移。
type dynModelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
// ModelID / Model id 的宽松回退键(仅 global 目录的多信封兜底用,CN 目录
// 不下发这两个键;字段加在这里只是让 typed 解析能"看见"它们)。
ModelID string `json:"modelId"`
Model string `json:"model"`
Description string `json:"descriptionZh"`
Credits string `json:"credits"`
Tags []string `json:"tags"`
Vendor string `json:"vendor"`
IsDefault bool `json:"isDefault"`
MaxInputTokens int64 `json:"maxInputTokens"`
MaxOutputTokens int64 `json:"maxOutputTokens"`
MaxAllowedSize int64 `json:"maxAllowedSize"`
Disabled bool `json:"disabled"`
SupportsImages bool `json:"supportsImages"`
SupportsReason bool `json:"supportsReasoning"`
SupportsTool bool `json:"supportsToolCall"`
OnlyReasoning bool `json:"onlyReasoning"`
Reasoning struct {
Effort string `json:"effort"`
Summary string `json:"summary"`
DefaultEffort string `json:"defaultEffort"`
CanDisableThinking bool `json:"canDisableThinking"`
SupportedEfforts []string `json:"supportedEfforts"`
} `json:"reasoning"`
}
// modelInfo 按解析条目构造 ModelInfo(dynEntry→ModelInfo 映射的单一事实来源)。
// defaultEffort 新老双键兼容:defaultEffort 优先,缺省回落 effort。
func (m dynModelEntry) modelInfo() ModelInfo {
def := m.Reasoning.DefaultEffort
if def == "" {
def = m.Reasoning.Effort
}
return ModelInfo{
ID: m.ID,
Name: m.Name,
ContextWindow: m.MaxInputTokens,
MaxTokens: m.MaxOutputTokens,
Efforts: m.Reasoning.SupportedEfforts,
DefaultEffort: def,
SupportsImages: m.SupportsImages,
Description: m.Description,
Credits: m.Credits,
Tags: m.Tags,
Vendor: m.Vendor,
IsDefault: m.IsDefault,
SupportsReasoning: m.SupportsReason,
SupportsToolCall: m.SupportsTool,
OnlyReasoning: m.OnlyReasoning,
MaxAllowedSize: m.MaxAllowedSize,
CanDisableThinking: m.Reasoning.CanDisableThinking,
ReasoningEffort: m.Reasoning.Effort,
ReasoningSummary: m.Reasoning.Summary,
}
}
// nonChatModel 判定是否非对话模型(应从模型列表过滤掉)。
// 来源:harness buddy.ts:547-555。三类规则:
// - id 前缀 nes-/completion-/codewise-:嵌入/补全/代码专用模型,选了报 code=11102。
// - maxOutputTokens ≤ 256:tiny 输出非对话模型。
// - tags 含生成类标签(图片/视频):生成模型走各自专用端点,作为对话模型
// 选上去只会报 11102,非本网关用途。
//
// 生成类标签随上游扩充:早期只有 text-to-image,桌面端目录(2026-10-02 实测)
// 另有 text-to-video / image-to-video(seedance 系列)与 image-to-image
// (gpt-image 系列)——后者已由 text-to-image 覆盖,此处补齐视频两类。
// 注意本函数 CN 与 global 共用,新增标签对两域同时生效。
func nonChatModel(id string, maxOutputTokens int64, tags []string) bool {
id = strings.ToLower(strings.TrimSpace(id))
for _, p := range [...]string{"nes-", "completion-", "codewise-"} {
if strings.HasPrefix(id, p) {
return true
}
}
if maxOutputTokens > 0 && maxOutputTokens <= 256 {
return true
}
for _, t := range tags {
switch t {
case "text-to-image", "image-to-image", "text-to-video", "image-to-video":
return true
}
}
return false
}
// codeBuddyIDEUA /v3/config 要求能解析出 CodeBuddy 版本号的 UA。
// CLI 三段式 WorkBuddy UA 会拿到精简目录(flash 输出 128K、无 supportedEfforts);
// 官方 IDE 头 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 才返回完整能力
// (flash:393216 + low/high/max)。
// 版本号需随上游 IDE 发版跟进:UAn 版本过旧时该端点可能同样返回精简目录。
const codeBuddyIDEUA = "CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0"
// codeBuddyCLIUA CLI 三段式 UA。**实测(2026-09-22)该端点对不同 UA 下发的模型集合不同**:
// - IDE UA → 14 条(10 个 chat:含 o4-mini / enhance-1.0 / auto-chat,**无 deepseek 系列**)
// - CLI UA → 22 条(22 个 chat:**含 deepseek-v4.1-flash / deepseek-v4.1-flash-sg /
// gpt-6-astra / kimi-k2.8-preview**,但无 o4-mini / enhance-1.0 / auto-chat)
//
// 注意两点,都与旧注释相反,勿再按旧注释推断:
// 1. 旧注释称「CLI UA 拿到精简目录、IDE UA 才返回完整能力」——实测模型数量恰好相反,
// 但 **IDE 响应体积更大**(26003B vs 21111B),故「完整能力」应理解为**单条字段更全**,
// 而非模型更多。两路各有独有模型,缺一不可。
// 2. 该常量仅用于 global 侧第二路探测;CN 侧仍走 codeBuddyIDEUA 单路。
const codeBuddyCLIUA = "CLI/2.63.2 CodeBuddy/2.63.2"
// FetchModels 调上游动态模型接口(CN 侧;global 账号见 global_models.go 家族)。
//
// v3-config-merge:动态目录 = /v3/config(主,IDE UA 完整能力版)+ 企业端点
// (/console,cli 面过滤,补缺)的并集,两路**并发**探测。合并去重 key = 模型 id,
// v3 条目优先(credits 等字段以 v3 为准),企业端点只补 v3 缺失的模型。
// /v3 失败(400/网络错/解析失败)不拖累企业端点结果——降级为仅企业端点,warn 日志;
// 反之亦然(两路独立容错)。
func (c *Client) FetchModels(a *auth.Auth) ([]ModelInfo, error) {
type probeResult struct {
infos []ModelInfo
err error
}
enterpriseCh := make(chan probeResult, 1)
v3Ch := make(chan probeResult, 1)
go func() {
infos, err := c.fetchEnterpriseModels(a)
enterpriseCh <- probeResult{infos, err}
}()
go func() {
infos, err := c.fetchV3Models(a)
v3Ch <- probeResult{infos, err}
}()
enterprise := <-enterpriseCh
v3 := <-v3Ch
if enterprise.err != nil && v3.err != nil {
return nil, enterprise.err // 两路全失败:返回企业端点错误(既有调用方语义零漂移)
}
if v3.err != nil {
// /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。
log.Printf("WARN: [upstream] fetch models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err)
}
if enterprise.err != nil {
log.Printf("WARN: [upstream] fetch models: enterprise endpoint failed (v3/config only): %v", enterprise.err)
}
out := mergeModelInfos(v3.infos, enterprise.infos)
if len(out) == 0 {
return nil, fmt.Errorf("models api returned empty list")
}
c.storeModelRates(a.Realm(), out)
// 刷新 effort 能力缓存(供请求体降级;无 supportedEfforts 的模型不入桶)。
// 空桶时跳过写:避免「某探测无档位数据」清掉既有桶。
cache := make(map[string][]string, len(out))
defCache := make(map[string]string, len(out))
for _, mi := range out {
if len(mi.Efforts) > 0 {
cache[mi.ID] = mi.Efforts
}
if mi.DefaultEffort != "" {
defCache[mi.ID] = mi.DefaultEffort
}
}
if len(cache) == 0 && len(defCache) == 0 {
return out, nil
}
// 按探测账号的 realm 写入对应桶:CN 探测只进 cn 桶,global 同模型名不被污染(C-2)。
c.storeEfforts(a.Realm(), cache, defCache)
return out, nil
}
// mergeModelInfos 合并两路模型目录:primary 为主(同 id 以 primary 条目为准——
// credits 等字段以主端点为权威),secondary 只补 primary 缺失的 id。
// 去重 key = 模型 id;输出顺序 = primary 原序在前、secondary 补充项(secondary 原序)
// 在后——稳定输出,不依赖 map 迭代序。
func mergeModelInfos(primary, secondary []ModelInfo) []ModelInfo {
if len(secondary) == 0 {
return primary
}
seen := make(map[string]bool, len(primary)+len(secondary))
out := make([]ModelInfo, 0, len(primary)+len(secondary))
for _, mi := range primary {
if mi.ID == "" || seen[mi.ID] {
continue
}
seen[mi.ID] = true
out = append(out, mi)
}
for _, mi := range secondary {
if mi.ID == "" || seen[mi.ID] {
continue
}
seen[mi.ID] = true
out = append(out, mi)
}
return out
}
// fetchEnterpriseModels 单路探测企业模型端点(/console/enterprises/personal/models)。
// 解析口径:agents[cli].models 过滤 + nonChatModel 剔除 + disabled 剔除。
func (c *Client) fetchEnterpriseModels(a *auth.Auth) ([]ModelInfo, error) {
// 局部变量名避开 url(本包已 import net/url,同名会造成阅读混淆)。
endpoint := c.chatBase(a) + "/console/enterprises/personal/models"
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
c.CommonHeaders(req, a) // 复用共享请求头(Origin/Referer/UA/Accept/Content-Type)
// AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。
req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue())
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
// 读失败 → 传输层错误(handler 侧该路径不 NoteError)。
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("models api status %d: %s", resp.StatusCode, truncate(string(raw), 120))
}
var env struct {
Code int `json:"code"`
Data struct {
Models []dynModelEntry `json:"models"`
Agents []struct {
Name string `json:"name"`
Models []string `json:"models"`
} `json:"agents"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("models parse: %w", err)
}
if env.Code != 0 {
return nil, fmt.Errorf("models api code=%d", env.Code)
}
var cliIDs []string
for _, ag := range env.Data.Agents {
if ag.Name == "cli" {
cliIDs = ag.Models
break
}
}
if len(cliIDs) == 0 {
return nil, fmt.Errorf("no cli agent models found")
}
// dynMap 收集模型字段;nonChatModel 过滤在写入 dynMap 前执行,
// 确保非对话条目(nes-/completion-/codewise- 前缀、maxOutputTokens≤256、
// tags 含 text-to-image)根本不进返回列表(来源:harness buddy.ts:547-555)。
dynMap := make(map[string]dynModelEntry, len(env.Data.Models))
for _, m := range env.Data.Models {
if nonChatModel(m.ID, m.MaxOutputTokens, m.Tags) {
continue
}
dynMap[m.ID] = m
}
out := make([]ModelInfo, 0, len(cliIDs))
for _, id := range cliIDs {
if m, ok := dynMap[id]; ok && !m.Disabled {
out = append(out, m.modelInfo())
}
}
if len(out) == 0 {
return nil, fmt.Errorf("models api returned empty list")
}
return out, nil
}
// fetchV3Models 单路探测 /v3/config(IDE UA 完整能力版,见 codeBuddyIDEUA)。
// v3 面取全量 models(不按 agents[cli] 过滤,与 global 探测口径一致),按同一
// nonChatModel 规则剔除非对话条目(selected 会选模型报 code=11102)。
// 失败返回错误(调用方降级为仅企业端点)。
func (c *Client) fetchV3Models(a *auth.Auth) ([]ModelInfo, error) {
byID, err := c.fetchV3ConfigModelMap(a, codeBuddyIDEUA)
if err != nil {
return nil, err
}
out := make([]ModelInfo, 0, len(byID))
for _, mi := range byID {
if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) {
continue
}
out = append(out, mi)
}
if len(out) == 0 {
return nil, fmt.Errorf("v3/config returned empty models")
}
return out, nil
}
// v3ModelPromotion /v3/config data.modelPromotions 单条优惠定义(2026-09-23 实测
// 7 条:deepseek 系错峰五折、glm-5.2 夜间五折、hy3 与 hy4-preview-f 限时免费)。
// discount 只在部分条目上存在:有 factor 的可算生效价;「错峰使用」类只有时段
// 文案(factor 藏在 hover 文本里,无机器可读值),仅透出标签与说明。
type v3ModelPromotion struct {
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
ModelIDs []string `json:"modelIds"`
Badge *struct {
Label string `json:"label"`
} `json:"badge"`
Discount *struct {
DiscountedCredits string `json:"discountedCredits"`
Factor float64 `json:"factor"`
} `json:"discount"`
Hover *struct {
TextZh string `json:"textZh"`
} `json:"hover"`
Schedule *struct {
Daily []struct {
Start string `json:"start"` // "23:00"
End string `json:"end"` // "7:50"(可跨午夜)
} `json:"daily"`
Timezone string `json:"timezone"` // 实测恒 Asia/Shanghai
ValidFrom string `json:"validFrom"` // RFC3339,可缺省
ValidUntil string `json:"validUntil"`
} `json:"schedule"`
}
// promoZone 优惠时区:上游恒 Asia/Shanghai(UTC+8 无夏令时),用 FixedZone 免依赖
// 系统 tzdata(Windows 无 IANA 库时 LoadLocation 会失败)。
var promoZone = time.FixedZone("CST", 8*3600)
// promoClock 解析 "HH:MM" 为当日分钟数;坏值返回 (-1, false)。
func promoClock(hhmm string) (int, bool) {
parts := strings.Split(hhmm, ":")
if len(parts) != 2 {
return -1, false
}
h, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
m, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil || h < 0 || h > 24 || m < 0 || m > 59 {
return -1, false
}
return h*60 + m, true
}
// promoActive 评估优惠在 now 是否生效:enabled + validFrom/validUntil 内 + 落在
// 任一 daily 窗口(支持跨午夜,如 23:00→7:50)。schedule 为 nil 视为全天生效。
func promoActive(p *v3ModelPromotion, now time.Time) bool {
if !p.Enabled {
return false
}
if sc := p.Schedule; sc != nil {
if sc.ValidFrom != "" {
from, err := time.Parse(time.RFC3339, sc.ValidFrom)
if err == nil && now.Before(from) {
return false
}
}
if sc.ValidUntil != "" {
until, err := time.Parse(time.RFC3339, sc.ValidUntil)
if err == nil && !now.Before(until) {
return false
}
}
if len(sc.Daily) > 0 {
cur := now.Hour()*60 + now.Minute()
inWindow := false
for _, w := range sc.Daily {
st, ok1 := promoClock(w.Start)
ed, ok2 := promoClock(w.End)
if !ok1 || !ok2 {
continue
}
if st <= ed {
if cur >= st && cur < ed {
inWindow = true
break
}
} else if cur >= st || cur < ed { // 跨午夜(23:00→7:50)
inWindow = true
break
}
}
if !inWindow {
return false
}
}
}
return true
}
// applyModelPromotions 把当前生效的优惠挂到目录条目:同模型多条命中取 priority
// 最高(实测 glm-5.2 白天 badge-only(50) 与夜间五折(100) 靠 priority+daily 双轨
// 切换)。无 discount 对象的条目也挂标签/说明(错峰类),PromoFactor 留 nil。
func applyModelPromotions(out map[string]ModelInfo, promos []v3ModelPromotion) {
if len(promos) == 0 || len(out) == 0 {
return
}
now := time.Now().In(promoZone)
type cand struct {
prio int
p *v3ModelPromotion
}
best := map[string]cand{}
for i := range promos {
p := &promos[i]
if !promoActive(p, now) {
continue
}
for _, id := range p.ModelIDs {
if _, ok := out[id]; !ok {
continue // 目录外模型(如同名 global 变体)不挂
}
if b, seen := best[id]; !seen || p.Priority > b.prio {
best[id] = cand{prio: p.Priority, p: p}
}
}
}
for id, c := range best {
mi := out[id]
if c.p.Badge != nil {
mi.PromoLabel = c.p.Badge.Label
}
if c.p.Hover != nil {
mi.PromoNote = c.p.Hover.TextZh
}
if c.p.Discount != nil {
f := c.p.Discount.Factor
mi.PromoFactor = &f
mi.PromoCredits = c.p.Discount.DiscountedCredits
}
out[id] = mi
}
}
// storeEfforts 按 realm 写入 effort 能力缓存桶(efforts + defaultEfforts),并发安全。
// 供 CN FetchModels 与 global 探测共用:拉取到的模型档位落桶后,出站请求体
// normalizeReasoningEffort 才能按域降级。efforts 与 defs 均空时删除该 realm 桶
// (等价「该域无可降级档位」)。调用方负责在「无新数据」时跳过写。
func (c *Client) storeEfforts(realm string, efforts map[string][]string, defs map[string]string) {
c.effortsMu.Lock()
defer c.effortsMu.Unlock()
if c.efforts == nil {
c.efforts = make(map[string]map[string][]string)
}
if c.defaultEfforts == nil {
c.defaultEfforts = make(map[string]map[string]string)
}
k := realmKey(realm)
if len(efforts) == 0 && len(defs) == 0 {
delete(c.efforts, k)
delete(c.defaultEfforts, k)
return
}
c.efforts[k] = efforts
c.defaultEfforts[k] = defs
}
// normalizeModelRate 把上游倍率原文规范化为可比较的数值键。
// 兼容 "x0.05" / "x0.05 credits" / "0.50x" 等形态;无法数值化时保留去除
// credits 后缀与空白后的原文,避免编造倍率。
func normalizeModelRate(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
if strings.HasSuffix(strings.ToLower(s), "credits") {
s = strings.TrimSpace(s[:len(s)-len("credits")])
}
if strings.HasPrefix(strings.ToLower(s), "x") {
s = strings.TrimSpace(s[1:])
} else if strings.HasSuffix(strings.ToLower(s), "x") {
s = strings.TrimSpace(s[:len(s)-1])
}
if s == "" {
return ""
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return strings.TrimSpace(raw)
}
return strconv.FormatFloat(v, 'f', -1, 64)
}
// effectiveModelRate 返回模型当前生效倍率:有机器可读优惠时取折扣价,
// 否则取牌价;两者均缺省时为空。
func effectiveModelRate(mi ModelInfo) string {
if mi.PromoFactor != nil && strings.TrimSpace(mi.PromoCredits) != "" {
return normalizeModelRate(mi.PromoCredits)
}
return normalizeModelRate(mi.Credits)
}
// storeModelRates 按 realm 整体替换模型倍率快照。目录成功刷新但没有可解析
// 倍率时写入空桶,使旧倍率不会继续冒充当前价。
func (c *Client) storeModelRates(realm string, infos []ModelInfo) {
rates := make(map[string]string, len(infos))
for _, mi := range infos {
if mi.ID == "" {
continue
}
if rate := effectiveModelRate(mi); rate != "" {
rates[mi.ID] = rate
}
}
c.effortsMu.Lock()
defer c.effortsMu.Unlock()
if c.modelRates == nil {
c.modelRates = make(map[string]map[string]string)
}
c.modelRates[realmKey(realm)] = rates
}
// ModelRate 返回最近成功刷新的指定域模型生效倍率;未知返回空串。
func (c *Client) ModelRate(realm, model string) string {
if c == nil || model == "" {
return ""
}
c.effortsMu.RLock()
defer c.effortsMu.RUnlock()
return c.modelRates[realmKey(realm)][model]
}
// GlobalEffortSnapshot 导出 global 域 effort 能力缓存(探测下发 ∪ 静态兜底合并后的桶),
// 供 /v1/models 输出 reasoning_supported_efforts / reasoning_default_effort。
// 返回副本;桶未填充(无 global 账号或从未探测)→ nil(调用方回落静态兜底表)。
func (c *Client) GlobalEffortSnapshot() (efforts map[string][]string, defaults map[string]string) {
return c.effortsSnapshot("global"), c.defaultEffortsSnapshot("global")
}
// v3ConfigDomain /v3/config 的 X-Domain:优先账号落盘 domain,否则 chatBase host。
func v3ConfigDomain(a *auth.Auth, chatBase string) string {
if a != nil {
// Domain 加锁快照(见 auth.DomainValue:keepalive 刷新在 a.mu 内改写)。
if d := strings.TrimSpace(a.DomainValue()); d != "" {
d = strings.TrimPrefix(d, "https://")
d = strings.TrimPrefix(d, "http://")
return strings.TrimSuffix(d, "/")
}
}
if u, err := url.Parse(chatBase); err == nil && u.Host != "" {
return u.Host
}
return "copilot.tencent.com"
}
// fetchV3ConfigModelMap 拉官方 IDE 配置目录,按模型 id 建能力表。
// 该端点对 UA 敏感:必须带 CodeBuddy/CodeBuddyIDE 版本,否则 400 code=12403。
// ua 为该次请求的 User-Agent;空串等价 codeBuddyIDEUA。该端点对 UA 敏感且**不同 UA 下发
// 不同模型集合**(见 codeBuddyCLIUA 注释),global 探测据此并发两路取并集。
func (c *Client) fetchV3ConfigModelMap(a *auth.Auth, ua string) (map[string]ModelInfo, error) {
req, err := http.NewRequest(http.MethodGet, c.chatBase(a)+"/v3/config", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
// AccessToken 加锁快照(同 fetchEnterpriseModels)。
req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue())
if a != nil && a.UID != "" {
req.Header.Set("X-User-Id", a.UID)
}
req.Header.Set("X-Domain", v3ConfigDomain(a, c.chatBase(a)))
req.Header.Set("X-Product", "SaaS")
if ua == "" {
ua = codeBuddyIDEUA
}
req.Header.Set("User-Agent", ua)
c.injectCodeBuddyRequest(req)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
// 读失败 → 传输层错误:半截 body 不进解析(不罚号)。
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("v3/config status %d: %s", resp.StatusCode, truncate(string(raw), 120))
}
var env struct {
Code int `json:"code"`
Data struct {
Models []dynModelEntry `json:"models"`
// 试用模型横幅:上游把「N 天免费试用」的模型放在这里,**不在 data.models 里**。
// 实测 global 侧 hy4-preview-f 只出现在此(modelId=hy4-preview-f、
// targetModelId=hy4-preview、trialDays=14),纯 data.models 解析会漏掉它。
ProductFeaturesConfig struct {
ModelTrialBanner struct {
Banners []struct {
ModelID string `json:"modelId"`
TargetModelID string `json:"targetModelId"`
} `json:"banners"`
} `json:"ModelTrialBanner"`
} `json:"productFeaturesConfig"`
ModelPromotions []v3ModelPromotion `json:"modelPromotions"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("v3/config parse: %w", err)
}
if env.Code != 0 {
return nil, fmt.Errorf("v3/config code=%d", env.Code)
}
out := make(map[string]ModelInfo, len(env.Data.Models))
for _, m := range env.Data.Models {
if strings.TrimSpace(m.ID) == "" {
continue
}
out[m.ID] = m.modelInfo()
}
// 补入试用横幅模型(ModelTrialBanner):上游把「N 天免费试用」的模型只放在这里,
// data.models 里没有,故纯目录解析会漏(实测 global 侧 hy4-preview-f 即如此,
// 但该模型**实际可调用**)。
//
// 元数据口径:能力字段(context/maxTokens/efforts/reasoning 等)从 targetModelId
// 的既有条目继承——试用版与其转正目标是同族模型,能力应当一致;
// 但 **Credits 与 Tags 显式清空**——它们描述的是"转正后"的计费与营销信息
// (如 hy4-preview 的 x0.29 与 badge),用在免费试用版上会误导下游展示。
//
// firstUseTimeKey / trialDays 属**账号级**试用状态,不透出给下游。
for _, b := range env.Data.ProductFeaturesConfig.ModelTrialBanner.Banners {
id := strings.TrimSpace(b.ModelID)
if id == "" {
continue
}
if _, exists := out[id]; exists {
continue
}
mi := ModelInfo{ID: id}
if tgt := strings.TrimSpace(b.TargetModelID); tgt != "" {
if base, ok := out[tgt]; ok {
mi = base
mi.ID = id
}
}
mi.Credits = ""
mi.Tags = nil
out[id] = mi
}
// 挂当前生效的限时优惠(modelPromotions):Credits 字段是**牌价**(转正后基准
// 倍率,如 hy4-preview-f 的 x0.29),而 WorkBuddy 客户端显示的是生效价(试用/
// 折扣窗口内 factor 打折)——面板据此展示「生效价 + 标签 + 牌价」。
applyModelPromotions(out, env.Data.ModelPromotions)
if len(out) == 0 {
return nil, fmt.Errorf("v3/config returned empty models")
}
return out, nil
}
// UserResource 查询账号积分余额与总额度(所有套餐聚合)。remain 负值钳 0;
// total 取与 remain 同源的额度字段(CycleCapacitySize 优先,无周期额度退
// CapacitySize),上游缺 size 的套餐按 remain 兜底,保证百分比不超 100%。
// CreditPackage 单个积分包的构成明细(面板「积分构成」用)。
//
// 两个账号即使任务完成度完全一致,余额也可能相差上千——差别藏在包的**面额与
// 来源**里(「国内运营裂变包」「拉新权益包」按次发放,面额 6~1500 不等)。
// 只看聚合值看不出这件事,所以把逐包明细暴露出来。
type CreditPackage struct {
Name string `json:"name"`
Remain int64 `json:"remain"`
Used int64 `json:"used"`
Size int64 `json:"size"`
// EndTime 该包的失效时刻:优先 DeductionEndTime(可抵扣窗口结束,真「用不完
// 就没了」),缺失依次回落 ExpiredTime / PackageEndTime / CycleEndTime(周期
// 边界,仅兜底)。RFC3339 或上游墙钟字符串,前端取日期部分展示。
EndTime string `json:"end_time,omitempty"`
// ExpiresAt 与 EndTime 同源的 Unix 毫秒时间戳,供面板按精确剩余天数聚合。
ExpiresAt int64 `json:"expires_at,omitempty"`
// CreatedAt 发放时刻,RFC3339。**这是区分「首登赠送」与「活动奖励」的唯一依据**:
// 两类包的 PackageName 与 PackageCode 完全相同(例如都是「国内运营裂变包」+
// TCACA_code_007_*),只看名字无法区分,只有时间能说明它是不是账号首次授权那刻发的。
CreatedAt string `json:"created_at,omitempty"`
// PackageCode / SubProductCode 上游的包类型标识。同 Name 不同 Code 的包可能
// 是不同来源;同 Code 不同面额则是同来源分批发放(首登 1500 与活动 300 即如此)。
PackageCode string `json:"package_code,omitempty"`
SubProductCode string `json:"sub_product_code,omitempty"`
SubProductName string `json:"sub_product_name,omitempty"`
// Cycle 为 true 表示按周期发放的包(读 Cycle* 字段),否则读 Capacity*。
Cycle bool `json:"cycle,omitempty"`
}
// CreditPackages 返回账号当前的逐包构成。remain/size 为各包求和。
//
// 字段选择与 UserResourceDetailed 的聚合口径一致:CycleCapacitySize > 0 时按
// 周期字段算,否则按 Capacity 字段算——两条路径不能混,否则同一个包会被算两次。
func (c *Client) CreditPackages(a *auth.Auth) ([]CreditPackage, int64, int64, error) {
now := time.Now()
body := map[string]any{
"PageNumber": 1,
"PageSize": 100,
"ProductCode": "p_tcaca",
"Status": []int{0, 3},
"PackageEndTimeRangeBegin": now.Format(packageEndLayout),
"PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout),
}
data, err := c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body)
if err != nil {
return nil, 0, 0, err
}
// 注意层级:doJSON 已经解过 apiEnvelope 并返回 env.Data,所以这里从
// Response 开始解析——**不能**再套一层 Code/Data,否则 Accounts 恒为空,
// 表现为「每个号都 0 个包」(实测踩过)。
var resp struct {
Response struct {
Data struct {
Accounts []struct {
PackageName string `json:"PackageName"`
CapacityRemain int64 `json:"CapacityRemain"`
CapacityUsed int64 `json:"CapacityUsed"`
CapacitySize int64 `json:"CapacitySize"`
CycleCapacityRemain int64 `json:"CycleCapacityRemain"`
CycleCapacityUsed int64 `json:"CycleCapacityUsed"`
CycleCapacitySize int64 `json:"CycleCapacitySize"`
// 到期时间字段名在上游存在三种口径:ExpiredTime / PackageEndTime
// 在 CN/global 实测字段全集里均恒 miss(见 UserResourceDetailed
// 处注释),真实下发的是 CycleEndTime——三者都读,谁有值用谁。
ExpiredTime string `json:"ExpiredTime"`
PackageEndTime string `json:"PackageEndTime"`
CycleEndTime string `json:"CycleEndTime"`
// DeductionEndTime 可抵扣窗口结束(epoch 毫秒)——「这个包什么时候
// 不能再花」的真失效时刻。CycleEndTime 是周期边界(额度重置点),
// 两者语义不同:判「用不完就没了」以本字段为准,CycleEndTime 兜底
//(OkRoromori 分支实测结论:请求参数叫 PackageEndTimeRange*,但
// 响应里 ExpiredTime 恒空,真正的失效时刻只有这里下发)。
DeductionEndTime int64 `json:"DeductionEndTime"`
// 发放时刻(epoch 毫秒)。
CreateTime int64 `json:"CreateTime"`
PackageCode string `json:"PackageCode"`
SubProductCode string `json:"SubProductCode"`
SubProductName string `json:"SubProductName"`
} `json:"Accounts"`
} `json:"Data"`
} `json:"Response"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, 0, 0, fmt.Errorf("packages parse: %w", err)
}
packs := resp.Response.Data.Accounts
out := make([]CreditPackage, 0, len(packs))
var sumRemain, sumSize int64
for _, p := range packs {
cp := CreditPackage{
Name: p.PackageName,
PackageCode: p.PackageCode,
SubProductCode: p.SubProductCode,
SubProductName: p.SubProductName,
}
switch {
case p.DeductionEndTime > 0:
// 真失效时刻(可抵扣窗口结束),语义见上方字段注释:判「用不完就没了」
// 用它而不是周期边界。epoch 毫秒 → RFC3339,与 CycleEndTime 字符串口径
// 共存(前端统一 slice(0,10) 取日期)。ExpiresAt 直接用原始毫秒——
// RFC3339 不是 packageEndLayout 形态,交给下方解析会静默失败得 0。
cp.EndTime = time.UnixMilli(p.DeductionEndTime).Format(time.RFC3339)
cp.ExpiresAt = p.DeductionEndTime
case p.ExpiredTime != "":
cp.EndTime = p.ExpiredTime
case p.PackageEndTime != "":
cp.EndTime = p.PackageEndTime
default:
cp.EndTime = p.CycleEndTime
}
if cp.ExpiresAt == 0 && cp.EndTime != "" {
if end, perr := time.ParseInLocation(packageEndLayout, cp.EndTime, softRateResetLoc); perr == nil {
cp.ExpiresAt = end.UnixMilli()
}
}
// CreateTime 是 epoch 毫秒;0 表示上游没给,留空而不是伪造 1970。
if p.CreateTime > 0 {
cp.CreatedAt = time.UnixMilli(p.CreateTime).Format(time.RFC3339)
}
if p.CycleCapacitySize > 0 {
cp.Cycle = true
cp.Remain, cp.Size = p.CycleCapacityRemain, p.CycleCapacitySize
cp.Used = cp.Size - cp.Remain
if p.CycleCapacityUsed > cp.Used {
cp.Used = p.CycleCapacityUsed
cp.Remain = cp.Size - cp.Used
}
if cp.Remain < 0 {
cp.Remain = 0
}
} else {
cp.Remain, cp.Used, cp.Size = p.CapacityRemain, p.CapacityUsed, p.CapacitySize
if cp.Used == 0 && cp.Size > cp.Remain {
cp.Used = cp.Size - cp.Remain
}
}
sumRemain += cp.Remain
sumSize += cp.Size
out = append(out, cp)
}
// 面额降序:大包一眼可见,正是差异最可能出现的地方。
sort.SliceStable(out, func(i, j int) bool { return out[i].Size > out[j].Size })
return out, sumRemain, sumSize, nil
}
func (c *Client) UserResource(a *auth.Auth) (remain, total int64, err error) {
remain, total, _, err = c.UserResourceDetailed(a, 0)
return remain, total, err
}
// packageEndLayout 上游套餐到期时间的墙钟格式(UTC+8,与 softRateResetLoc 同口径)。
const packageEndLayout = "2006-01-02 15:04:05"
// parsePackageEndTime 统一解析上游套餐到期时间。空值、格式异常返回 false,
// 调用方据此保守地不把该包计入最早到期路由。
func parsePackageEndTime(raw string) (time.Time, bool) {
if raw == "" {
return time.Time{}, false
}
t, err := time.ParseInLocation(packageEndLayout, raw, softRateResetLoc)
if err != nil {
return time.Time{}, false
}
return t, true
}
// UserResourceDetailed 在 UserResource 基础上额外返回「快过期」积分子集:
// soon > 0 且套餐 CycleEndTime 解析成功且到期时刻 ≤ now+soon 的余额计入 expiring
// (pool 据此优先消耗,避免官方活动赠送的奖励积分到期作废);soon ≤ 0 时 expiring
// 恒 0(禁用分桶,行为与引入前一致)。expiring 是 remain 的一部分。
//
// 到期时间判据是 CycleEndTime(上游实测:CN/global 两域字段全集均无 PackageEndTime,
// 旧判据恒 miss 致 expiring 恒 0;CycleEndTime 是上游真实下发的到期时刻——
// global Bonus Pack 14 天赠送积分的到期时间即此字段)。解析失败/缺失的套餐保守
// 不计入 expiring(不误标为快过期而插队)。
// 单套餐取数统一调 packageRemainUsed(与 CreditPackages 同一事实来源,含 remain
// 钳 [0,size] 与 used 修正;消除双份逻辑漂移——旧中间 switch 只钳负值,上游脏数据
// CycleRemain>Size 时会高估)。
func (c *Client) UserResourceDetailed(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, err error) {
remain, total, expiring, _, _, err = c.UserResourceDetailedWithExpiry(a, soon)
return remain, total, expiring, err
}
// UserResourceDetailedWithExpiry 在 UserResourceDetailed 基础上返回最早未来到期批次:
// earliestAt 是最早的可用到期时刻,earliestRemaining 是同一时刻所有正余额包的剩余量之和。
// 已过期、剩余为 0、缺少或无法解析到期时间的包都不会成为最早批次;无有效批次时返回零值。
func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, err error) {
now := time.Now()
body := map[string]any{
"PageNumber": 1,
"PageSize": 100,
"ProductCode": "p_tcaca",
"Status": []int{0, 3},
"PackageEndTimeRangeBegin": now.Format(packageEndLayout),
"PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout),
}
// 余额查询同样做瞬时错误有界重试(签到后紧接着的 user-resource 偶发 500 会让
// 该账号错过本次解冻/到期快照更新,只能等下一个刷新周期)。
var data json.RawMessage
err = c.retryBillingTransient(func() error {
var e error
data, e = c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body)
return e
})
if err != nil {
return 0, 0, 0, time.Time{}, 0, err
}
var resp struct {
Response struct {
Data struct {
Accounts []struct {
PackageName string `json:"PackageName"`
CycleEndTime string `json:"CycleEndTime"` // "2006-01-02 15:04:05",缺省/空 = 无到期
CapacitySize int64 `json:"CapacitySize"`
CapacityRemain int64 `json:"CapacityRemain"`
CapacityUsed int64 `json:"CapacityUsed"`
CycleCapacitySize int64 `json:"CycleCapacitySize"`
CycleCapacityRemain int64 `json:"CycleCapacityRemain"`
CycleCapacityUsed int64 `json:"CycleCapacityUsed"`
} `json:"Accounts"`
} `json:"Data"`
} `json:"Response"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return 0, 0, 0, time.Time{}, 0, fmt.Errorf("resource parse: %w", err)
}
for _, acct := range resp.Response.Data.Accounts {
r, _, size := packageRemainUsed(respAccount{
CapacityRemain: acct.CapacityRemain,
CapacityUsed: acct.CapacityUsed,
CapacitySize: acct.CapacitySize,
CycleCapacityRemain: acct.CycleCapacityRemain,
CycleCapacityUsed: acct.CycleCapacityUsed,
CycleCapacitySize: acct.CycleCapacitySize,
})
if r < 0 {
r = 0
}
if size < r {
size = r
}
remain += r
total += size
if r <= 0 {
continue
}
end, ok := parsePackageEndTime(acct.CycleEndTime)
if !ok || !end.After(now) {
continue
}
if earliestAt.IsZero() || end.Before(earliestAt) {
earliestAt = end
earliestRemaining = r
} else if end.Equal(earliestAt) {
earliestRemaining += r
}
// 分桶:仅 soon>0 且确实在窗口内 → expiring。
if soon > 0 && !end.After(now.Add(soon)) {
expiring += r
}
}
return remain, total, expiring, earliestAt, earliestRemaining, nil
}
// respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。
type respAccount struct {
CapacityRemain int64
CapacityUsed int64
CapacitySize int64
CycleCapacityRemain int64
CycleCapacityUsed int64
CycleCapacitySize int64
}
// packageRemainUsed 聚合单套餐的 remain/used/size(与 CreditPackages/cmd/credit 的
// 历史口径一致,收敛至此作为单一事实来源)。Cycle 期套餐优先:用 CycleCapacity
// 三字段,used 取 CycleUsed 与 size-remain 的较大者;否则回退 Capacity 三字段。
func packageRemainUsed(a respAccount) (remain, used, size int64) {
if a.CycleCapacitySize > 0 {
remain = a.CycleCapacityRemain
size = a.CycleCapacitySize
if remain < 0 {
remain = 0
}
if remain > size {
remain = size
}
used = size - remain
if a.CycleCapacityUsed > used {
used = a.CycleCapacityUsed
if size >= used {
remain = size - used
}
}
return remain, used, size
}
remain = a.CapacityRemain
used = a.CapacityUsed
size = a.CapacitySize
if used == 0 && size > remain {
used = size - remain
}
return remain, used, size
}
// DailyCheckin 执行每日签到。已签到(业务 code 非 0)也返回错误,调用方按 msg 区分。
// 偶发上游 5xx(code 10000)做有界重试(见 retryBillingTransient)——单次抖动不再
// 让该账号整天漏签;「已签到」等业务错误不重试。
func (c *Client) DailyCheckin(a *auth.Auth) error {
return c.retryBillingTransient(func() error {
_, err := c.billingMeterJSON(a, c.checkinMeterPaths(a), http.MethodPost, map[string]any{})
return err
})
}
// IsAlreadyCheckin 报告 err 是否表示"今天已签到"(上游幂等拒绝重复签到)。
// 只认带分类的 *Error(业务 code 或 HTTP 错误):网络层/解析层错误不得当作幂等成功,
// 否则停机补签遇到抖动会误记为 already,账号当天实际未签到却被判定正常。
func IsAlreadyCheckin(err error) bool {
var ue *Error
if !errors.As(err, &ue) {
return false
}
for _, m := range alreadyCheckinMarkers {
if strings.Contains(ue.Msg, m) || strings.Contains(strings.ToLower(ue.Msg), strings.ToLower(m)) {
return true
}
}
return false
}
func truncate(s string, n int) string {
return logfmt.Truncate(s, n)
}
|