diff --git a/package.json b/package.json index c81068792..33b7e7d03 100644 --- a/package.json +++ b/package.json @@ -26,16 +26,16 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.20", "cross-fetch": "^4.1.0", "jsdom": "^25.0.0", "msw": "^2.11.6", + "postcss": "^8.4.49", "prettier": "^3.6.2", + "tailwindcss": "^3.4.17", "typescript": "^5.3.0", "vite": "^5.0.0", - "vitest": "^2.0.5", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17" + "vitest": "^2.0.5" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.4", @@ -75,6 +75,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.65.0", "react-i18next": "^16.0.0", + "recharts": "^3.5.1", "smol-toml": "^1.4.2", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5fb99387..262442bd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: react-i18next: specifier: ^16.0.0 version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) + recharts: + specifier: ^3.5.1 + version: 3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1) smol-toml: specifier: ^1.4.2 version: 1.4.2 @@ -1047,6 +1050,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.0': + resolution: {integrity: sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1161,6 +1175,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -1307,6 +1324,33 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1327,6 +1371,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1526,6 +1573,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1539,6 +1630,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1609,6 +1703,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.42.0: + resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1621,6 +1718,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -1738,10 +1838,20 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.0.1: + resolution: {integrity: sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -2115,6 +2225,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2160,14 +2282,33 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts@3.5.1: + resolution: {integrity: sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -2284,6 +2425,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2392,9 +2536,17 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3416,6 +3568,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@standard-schema/utils': 0.3.0 + immer: 11.0.1 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.46.2': @@ -3478,6 +3642,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true + '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} '@tanstack/query-core@5.90.3': {} @@ -3609,6 +3775,30 @@ snapshots: dependencies: '@babel/types': 7.28.2 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/node@20.19.9': @@ -3628,6 +3818,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))': dependencies: '@babel/core': 7.28.0 @@ -3841,6 +4033,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3850,6 +4080,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} deep-eql@5.0.2: {} @@ -3902,6 +4134,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.42.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -3934,6 +4168,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventemitter3@5.0.1: {} + expect-type@1.2.2: {} fast-glob@3.3.3: @@ -4051,8 +4287,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immer@10.2.0: {} + + immer@11.0.1: {} + indent-string@4.0.0: {} + internmap@2.0.3: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -4352,6 +4594,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1): @@ -4393,13 +4644,41 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts@3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.42.0 + eventemitter3: 5.0.1 + immer: 10.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + require-directory@2.1.1: {} + reselect@5.1.1: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -4545,6 +4824,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4629,8 +4910,29 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1): dependencies: cac: 6.7.14 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13fc4b2f0..bffed07fe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -257,6 +257,28 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-task" version = "4.7.1" @@ -320,6 +342,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -621,11 +698,15 @@ name = "cc-switch" version = "3.8.2" dependencies = [ "anyhow", + "async-stream", "auto-launch", + "axum", "base64 0.22.1", + "bytes", "chrono", "dirs 5.0.1", "futures", + "hyper", "indexmap 2.11.4", "log", "objc2 0.5.2", @@ -635,6 +716,7 @@ dependencies = [ "reqwest", "rquickjs", "rusqlite", + "rust_decimal", "serde", "serde_json", "serde_yaml", @@ -650,11 +732,14 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "toml 0.8.2", "toml_edit 0.22.27", + "tower 0.4.13", + "tower-http 0.5.2", "url", + "uuid", "winreg 0.52.0", "zip 2.4.2", ] @@ -783,6 +868,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -806,9 +901,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -819,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1204,6 +1299,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -1369,6 +1473,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1376,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1390,6 +1503,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1833,6 +1952,25 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.4", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1957,6 +2095,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.7.0" @@ -1967,9 +2111,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1995,6 +2141,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -2014,9 +2176,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2541,6 +2705,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.6" @@ -2610,6 +2780,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3027,6 +3214,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3770,16 +4001,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3790,10 +4026,11 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower", - "tower-http", + "tower 0.5.2", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", @@ -4037,6 +4274,15 @@ dependencies = [ "sdd", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.1", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4112,6 +4358,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4206,6 +4475,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4440,7 +4720,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -4581,6 +4861,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4602,7 +4903,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" dependencies = [ "bitflags 2.9.4", "block2 0.6.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -5241,6 +5542,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5378,6 +5689,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -5391,6 +5713,23 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", ] [[package]] @@ -5406,7 +5745,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -5429,6 +5768,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 832f51a9d..aba6b349c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2" dirs = "5.0" toml = "0.8" toml_edit = "0.22" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } futures = "0.3" +async-stream = "0.3" +bytes = "1.5" +axum = "0.7" +tower = "0.4" +tower-http = { version = "0.5", features = ["cors"] } +hyper = { version = "1.0", features = ["full"] } regex = "1.10" rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } -thiserror = "1.0" +thiserror = "2.0" anyhow = "1.0" zip = "2.2" serde_yaml = "0.9" @@ -53,6 +59,8 @@ once_cell = "1.21.3" base64 = "0.22" rusqlite = { version = "0.31", features = ["bundled", "backup"] } indexmap = { version = "2", features = ["serde"] } +rust_decimal = "1.33" +uuid = { version = "1.11", features = ["v4"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a98da4468..cd8018b8b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,11 +6,14 @@ mod env; mod import_export; mod mcp; mod misc; +mod model_test; mod plugin; mod prompt; mod provider; +mod proxy; mod settings; pub mod skill; +mod usage; pub use config::*; pub use deeplink::*; @@ -18,8 +21,11 @@ pub use env::*; pub use import_export::*; pub use mcp::*; pub use misc::*; +pub use model_test::*; pub use plugin::*; pub use prompt::*; pub use provider::*; +pub use proxy::*; pub use settings::*; pub use skill::*; +pub use usage::*; diff --git a/src-tauri/src/commands/model_test.rs b/src-tauri/src/commands/model_test.rs new file mode 100644 index 000000000..a751875f2 --- /dev/null +++ b/src-tauri/src/commands/model_test.rs @@ -0,0 +1,128 @@ +//! 模型测试相关命令 + +use crate::app_config::AppType; +use crate::error::AppError; +use crate::services::model_test::{ + ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService, +}; +use crate::store::AppState; +use tauri::State; + +/// 测试单个供应商的模型可用性 +#[tauri::command] +pub async fn test_provider_model( + state: State<'_, AppState>, + app_type: AppType, + provider_id: String, +) -> Result { + // 获取测试配置 + let config = state.db.get_model_test_config()?; + + // 获取供应商 + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers + .get(&provider_id) + .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; + + // 执行测试 + let result = ModelTestService::test_provider(&app_type, provider, &config).await?; + + // 记录日志 + let _ = state.db.save_model_test_log( + &provider_id, + &provider.name, + app_type.as_str(), + &result.model_used, + &config.test_prompt, + &result, + ); + + Ok(result) +} + +/// 批量测试所有供应商 +#[tauri::command] +pub async fn test_all_providers_model( + state: State<'_, AppState>, + app_type: AppType, + proxy_targets_only: bool, +) -> Result, AppError> { + let config = state.db.get_model_test_config()?; + let providers = state.db.get_all_providers(app_type.as_str())?; + + let mut results = Vec::new(); + + for (id, provider) in providers { + // 如果只测试代理目标,跳过非代理目标 + if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) { + continue; + } + + match ModelTestService::test_provider(&app_type, &provider, &config).await { + Ok(result) => { + // 记录日志 + let _ = state.db.save_model_test_log( + &id, + &provider.name, + app_type.as_str(), + &result.model_used, + &config.test_prompt, + &result, + ); + results.push((id, result)); + } + Err(e) => { + let error_result = ModelTestResult { + success: false, + message: e.to_string(), + response_time_ms: None, + http_status: None, + model_used: String::new(), + tested_at: chrono::Utc::now().timestamp(), + }; + results.push((id, error_result)); + } + } + } + + Ok(results) +} + +/// 获取模型测试配置 +#[tauri::command] +pub fn get_model_test_config(state: State<'_, AppState>) -> Result { + state.db.get_model_test_config() +} + +/// 保存模型测试配置 +#[tauri::command] +pub fn save_model_test_config( + state: State<'_, AppState>, + config: ModelTestConfig, +) -> Result<(), AppError> { + state.db.save_model_test_config(&config) +} + +/// 获取模型测试日志 +#[tauri::command] +pub fn get_model_test_logs( + state: State<'_, AppState>, + app_type: Option, + provider_id: Option, + limit: Option, +) -> Result, AppError> { + state.db.get_model_test_logs( + app_type.as_deref(), + provider_id.as_deref(), + limit.unwrap_or(50), + ) +} + +/// 清理旧的测试日志 +#[tauri::command] +pub fn cleanup_model_test_logs( + state: State<'_, AppState>, + keep_count: Option, +) -> Result { + state.db.cleanup_model_test_logs(keep_count.unwrap_or(100)) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index cce5aabd3..b25b63edd 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -86,6 +86,19 @@ pub fn switch_provider( .map_err(|e| e.to_string()) } +/// 设置代理目标供应商 +#[tauri::command] +pub fn set_proxy_target_provider( + state: State<'_, AppState>, + app: String, + id: String, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + ProviderService::set_proxy_target(state.inner(), app_type, &id) + .map(|_| true) + .map_err(|e| e.to_string()) +} + fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { ProviderService::import_default_config(state, app_type) } diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs new file mode 100644 index 000000000..7f30eea0c --- /dev/null +++ b/src-tauri/src/commands/proxy.rs @@ -0,0 +1,47 @@ +//! 代理服务相关的 Tauri 命令 +//! +//! 提供前端调用的 API 接口 + +use crate::proxy::types::*; +use crate::store::AppState; + +/// 启动代理服务器 +#[tauri::command] +pub async fn start_proxy_server( + state: tauri::State<'_, AppState>, +) -> Result { + state.proxy_service.start().await +} + +/// 停止代理服务器 +#[tauri::command] +pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> { + state.proxy_service.stop().await +} + +/// 获取代理服务器状态 +#[tauri::command] +pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result { + state.proxy_service.get_status().await +} + +/// 获取代理配置 +#[tauri::command] +pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result { + state.proxy_service.get_config().await +} + +/// 更新代理配置 +#[tauri::command] +pub async fn update_proxy_config( + state: tauri::State<'_, AppState>, + config: ProxyConfig, +) -> Result<(), String> { + state.proxy_service.update_config(&config).await +} + +/// 检查代理服务器是否正在运行 +#[tauri::command] +pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result { + Ok(state.proxy_service.is_running().await) +} diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs new file mode 100644 index 000000000..e872feb5d --- /dev/null +++ b/src-tauri/src/commands/usage.rs @@ -0,0 +1,178 @@ +//! 使用统计相关命令 + +use crate::error::AppError; +use crate::services::usage_stats::*; +use crate::store::AppState; +use tauri::State; + +/// 获取使用量汇总 +#[tauri::command] +pub fn get_usage_summary( + state: State<'_, AppState>, + start_date: Option, + end_date: Option, +) -> Result { + state.db.get_usage_summary(start_date, end_date) +} + +/// 获取每日趋势 +#[tauri::command] +pub fn get_usage_trends( + state: State<'_, AppState>, + days: u32, +) -> Result, AppError> { + state.db.get_daily_trends(days) +} + +/// 获取 Provider 统计 +#[tauri::command] +pub fn get_provider_stats(state: State<'_, AppState>) -> Result, AppError> { + state.db.get_provider_stats() +} + +/// 获取模型统计 +#[tauri::command] +pub fn get_model_stats(state: State<'_, AppState>) -> Result, AppError> { + state.db.get_model_stats() +} + +/// 获取请求日志列表 +#[tauri::command] +pub fn get_request_logs( + state: State<'_, AppState>, + filters: LogFilters, + page: u32, + page_size: u32, +) -> Result { + state.db.get_request_logs(&filters, page, page_size) +} + +/// 获取单个请求详情 +#[tauri::command] +pub fn get_request_detail( + state: State<'_, AppState>, + request_id: String, +) -> Result, AppError> { + state.db.get_request_detail(&request_id) +} + +/// 获取模型定价列表 +#[tauri::command] +pub fn get_model_pricing(state: State<'_, AppState>) -> Result, AppError> { + log::info!("获取模型定价列表"); + state.db.ensure_model_pricing_seeded()?; + + let db = state.db.clone(); + let conn = crate::database::lock_conn!(db.conn); + + // 检查表是否存在 + let table_exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'", + [], + |row| row.get::<_, i64>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !table_exists { + log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移"); + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare( + "SELECT model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + ORDER BY display_name", + )?; + + let rows = stmt.query_map([], |row| { + Ok(ModelPricingInfo { + model_id: row.get(0)?, + display_name: row.get(1)?, + input_cost_per_million: row.get(2)?, + output_cost_per_million: row.get(3)?, + cache_read_cost_per_million: row.get(4)?, + cache_creation_cost_per_million: row.get(5)?, + }) + })?; + + let mut pricing = Vec::new(); + for row in rows { + pricing.push(row?); + } + + log::info!("成功获取 {} 条模型定价数据", pricing.len()); + Ok(pricing) +} + +/// 更新模型定价 +#[tauri::command] +pub fn update_model_pricing( + state: State<'_, AppState>, + model_id: String, + display_name: String, + input_cost: String, + output_cost: String, + cache_read_cost: String, + cache_creation_cost: String, +) -> Result<(), AppError> { + let db = state.db.clone(); + let conn = crate::database::lock_conn!(db.conn); + + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + model_id, + display_name, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost + ], + ) + .map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?; + + Ok(()) +} + +/// 检查 Provider 使用限额 +#[tauri::command] +pub fn check_provider_limits( + state: State<'_, AppState>, + provider_id: String, + app_type: String, +) -> Result { + state.db.check_provider_limits(&provider_id, &app_type) +} + +/// 删除模型定价 +#[tauri::command] +pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> { + let db = state.db.clone(); + let conn = crate::database::lock_conn!(db.conn); + + conn.execute( + "DELETE FROM model_pricing WHERE model_id = ?1", + rusqlite::params![model_id], + ) + .map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?; + + log::info!("已删除模型定价: {model_id}"); + Ok(()) +} + +/// 模型定价信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelPricingInfo { + pub model_id: String, + pub display_name: String, + pub input_cost_per_million: String, + pub output_cost_per_million: String, + pub cache_read_cost_per_million: String, + pub cache_creation_cost_per_million: String, +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index b263079a1..78b3a7c55 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -1,11 +1,12 @@ -//! 数据访问对象 (DAO) 模块 +//! Data Access Object layer //! -//! 提供各类数据的 CRUD 操作。 +//! Database access operations for each domain -mod mcp; -mod prompts; -mod providers; -mod settings; -mod skills; +pub mod mcp; +pub mod prompts; +pub mod providers; +pub mod proxy; +pub mod settings; +pub mod skills; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 239ef52e3..c2fcc0b5e 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -17,7 +17,7 @@ impl Database { ) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta + "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target FROM providers WHERE app_type = ?1 ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -35,6 +35,7 @@ impl Database { let icon: Option = row.get(8)?; let icon_color: Option = row.get(9)?; let meta_str: String = row.get(10)?; + let is_proxy_target: bool = row.get(11)?; let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); @@ -54,6 +55,7 @@ impl Database { meta: Some(meta), icon, icon_color, + is_proxy_target: Some(is_proxy_target), }, )) }) @@ -121,6 +123,77 @@ impl Database { } } + /// 根据 ID 获取单个供应商 + pub fn get_provider_by_id( + &self, + id: &str, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let result = conn.query_row( + "SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target + FROM providers WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + |row| { + let name: String = row.get(0)?; + let settings_config_str: String = row.get(1)?; + let website_url: Option = row.get(2)?; + let category: Option = row.get(3)?; + let created_at: Option = row.get(4)?; + let sort_index: Option = row.get(5)?; + let notes: Option = row.get(6)?; + let icon: Option = row.get(7)?; + let icon_color: Option = row.get(8)?; + let meta_str: String = row.get(9)?; + let is_proxy_target: bool = row.get(10)?; + + let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); + let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); + + Ok(Provider { + id: id.to_string(), + name, + settings_config, + website_url, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon, + icon_color, + is_proxy_target: Some(is_proxy_target), + }) + }, + ); + + match result { + Ok(provider) => Ok(Some(provider)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 获取代理目标供应商 ID + pub fn get_proxy_target_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 LIMIT 1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt + .query(params![app_type]) + .map_err(|e| AppError::Database(e.to_string()))?; + + if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { + Ok(Some( + row.get(0).map_err(|e| AppError::Database(e.to_string()))?, + )) + } else { + Ok(None) + } + } + /// 保存供应商(新增或更新) /// /// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理 @@ -135,17 +208,17 @@ impl Database { let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current) - let existing: Option = tx + // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 is_proxy_target) + let existing: Option<(bool, bool)> = tx .query_row( - "SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2", + "SELECT is_current, is_proxy_target FROM providers WHERE id = ?1 AND app_type = ?2", params![provider.id, app_type], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .ok(); let is_update = existing.is_some(); - let is_current = existing.unwrap_or(false); + let (is_current, is_proxy_target) = existing.unwrap_or((false, false)); if is_update { // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE @@ -161,8 +234,9 @@ impl Database { icon = ?8, icon_color = ?9, meta = ?10, - is_current = ?11 - WHERE id = ?12 AND app_type = ?13", + is_current = ?11, + is_proxy_target = ?12 + WHERE id = ?13 AND app_type = ?14", params![ provider.name, serde_json::to_string(&provider.settings_config).unwrap(), @@ -175,6 +249,7 @@ impl Database { provider.icon_color, serde_json::to_string(&meta_clone).unwrap(), is_current, + is_proxy_target, provider.id, app_type, ], @@ -185,8 +260,8 @@ impl Database { tx.execute( "INSERT INTO providers ( id, app_type, name, settings_config, website_url, category, - created_at, sort_index, notes, icon, icon_color, meta, is_current - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + created_at, sort_index, notes, icon, icon_color, meta, is_current, is_proxy_target + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ provider.id, app_type, @@ -201,6 +276,7 @@ impl Database { provider.icon_color, serde_json::to_string(&meta_clone).unwrap(), is_current, + is_proxy_target, ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -256,6 +332,47 @@ impl Database { Ok(()) } + /// 设置代理目标供应商 + pub fn set_proxy_target_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + + // 重置所有为 0 + tx.execute( + "UPDATE providers SET is_proxy_target = 0 WHERE app_type = ?1", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 设置新的代理目标供应商 + tx.execute( + "UPDATE providers SET is_proxy_target = 1 WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 获取所有活跃的代理目标 + pub fn get_all_proxy_targets(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let targets = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(targets) + } + /// 添加自定义端点 pub fn add_custom_endpoint( &self, diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs new file mode 100644 index 000000000..187415f8d --- /dev/null +++ b/src-tauri/src/database/dao/proxy.rs @@ -0,0 +1,244 @@ +//! 代理功能数据访问层 +//! +//! 处理代理配置、Provider健康状态和使用统计的数据库操作 + +use crate::error::AppError; +use crate::proxy::types::*; + +use super::super::{lock_conn, Database}; + +impl Database { + // ==================== Proxy Config ==================== + + /// 获取代理配置 + pub async fn get_proxy_config(&self) -> Result { + // 在一个作用域内获取锁并查询,确保锁在await之前释放 + let result = { + let conn = lock_conn!(self.conn); + conn.query_row( + "SELECT enabled, listen_address, listen_port, max_retries, + request_timeout, enable_logging + FROM proxy_config WHERE id = 1", + [], + |row| { + Ok(ProxyConfig { + enabled: row.get::<_, i32>(0)? != 0, + listen_address: row.get(1)?, + listen_port: row.get::<_, i32>(2)? as u16, + max_retries: row.get::<_, i32>(3)? as u8, + request_timeout: row.get::<_, i32>(4)? as u64, + enable_logging: row.get::<_, i32>(5)? != 0, + }) + }, + ) + }; // conn锁在这里释放 + + match result { + Ok(config) => Ok(config), + Err(rusqlite::Error::QueryReturnedNoRows) => { + // 如果不存在,插入默认配置 + let default_config = ProxyConfig::default(); + self.update_proxy_config(default_config.clone()).await?; + Ok(default_config) + } + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 更新代理配置 + pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "INSERT OR REPLACE INTO proxy_config + (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, + COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), + datetime('now'))", + rusqlite::params![ + if config.enabled { 1 } else { 0 }, + config.listen_address, + config.listen_port as i32, + config.max_retries as i32, + config.request_timeout as i32, + if config.enable_logging { 1 } else { 0 }, + "claude", // 兼容旧字段,写入默认值 + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + // ==================== Provider Health ==================== + + /// 获取Provider健康状态 + pub async fn get_provider_health( + &self, + provider_id: &str, + app_type: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + conn.query_row( + "SELECT provider_id, app_type, is_healthy, consecutive_failures, + last_success_at, last_failure_at, last_error, updated_at + FROM provider_health + WHERE provider_id = ?1 AND app_type = ?2", + rusqlite::params![provider_id, app_type], + |row| { + Ok(ProviderHealth { + provider_id: row.get(0)?, + app_type: row.get(1)?, + is_healthy: row.get::<_, i64>(2)? != 0, + consecutive_failures: row.get::<_, i64>(3)? as u32, + last_success_at: row.get(4)?, + last_failure_at: row.get(5)?, + last_error: row.get(6)?, + updated_at: row.get(7)?, + }) + }, + ) + .map_err(|e| AppError::Database(e.to_string())) + } + + /// 更新Provider健康状态 + pub async fn update_provider_health( + &self, + provider_id: &str, + app_type: &str, + success: bool, + error_msg: Option, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + let now = chrono::Utc::now().to_rfc3339(); + + // 先查询当前状态 + let current = conn.query_row( + "SELECT consecutive_failures FROM provider_health + WHERE provider_id = ?1 AND app_type = ?2", + rusqlite::params![provider_id, app_type], + |row| Ok(row.get::<_, i64>(0)? as u32), + ); + + let (is_healthy, consecutive_failures) = if success { + // 成功:重置失败计数 + (1, 0) + } else { + // 失败:增加失败计数 + let failures = current.unwrap_or(0) + 1; + let healthy = if failures >= 3 { 0 } else { 1 }; + (healthy, failures) + }; + + let (last_success_at, last_failure_at) = if success { + (Some(now.clone()), None) + } else { + (None, Some(now.clone())) + }; + + // UPSERT + conn.execute( + "INSERT OR REPLACE INTO provider_health + (provider_id, app_type, is_healthy, consecutive_failures, + last_success_at, last_failure_at, last_error, updated_at) + VALUES (?1, ?2, ?3, ?4, + COALESCE(?5, (SELECT last_success_at FROM provider_health + WHERE provider_id = ?1 AND app_type = ?2)), + COALESCE(?6, (SELECT last_failure_at FROM provider_health + WHERE provider_id = ?1 AND app_type = ?2)), + ?7, ?8)", + rusqlite::params![ + provider_id, + app_type, + is_healthy, + consecutive_failures as i64, + last_success_at, + last_failure_at, + error_msg, + &now, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + // ==================== Proxy Usage (可选) ==================== + + /// 记录代理使用统计 + #[allow(dead_code)] + pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "INSERT INTO proxy_usage + (provider_id, app_type, endpoint, request_tokens, response_tokens, + status_code, latency_ms, error, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + &record.provider_id, + &record.app_type, + &record.endpoint, + record.request_tokens, + record.response_tokens, + record.status_code as i64, + record.latency_ms as i64, + &record.error, + &record.timestamp, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 查询最近的使用统计 + #[allow(dead_code)] + pub async fn get_recent_usage( + &self, + provider_id: &str, + app_type: &str, + limit: usize, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut stmt = conn + .prepare( + "SELECT provider_id, app_type, endpoint, request_tokens, response_tokens, + status_code, latency_ms, error, timestamp + FROM proxy_usage + WHERE provider_id = ?1 AND app_type = ?2 + ORDER BY timestamp DESC + LIMIT ?3", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let rows = stmt + .query_map( + rusqlite::params![provider_id, app_type, limit as i64], + |row| { + Ok(ProxyUsageRecord { + provider_id: row.get(0)?, + app_type: row.get(1)?, + endpoint: row.get(2)?, + request_tokens: row.get(3)?, + response_tokens: row.get(4)?, + status_code: row.get::<_, i64>(5)? as u16, + latency_ms: row.get::<_, i64>(6)? as u64, + error: row.get(7)?, + timestamp: row.get(8)?, + }) + }, + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut records = Vec::new(); + for row in rows { + records.push(row.map_err(|e| AppError::Database(e.to_string()))?); + } + + Ok(records) + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 4602c562c..2786a20b9 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -44,7 +44,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 1; +pub(crate) const SCHEMA_VERSION: i32 = 2; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { @@ -95,6 +95,7 @@ impl Database { }; db.create_tables()?; db.apply_schema_migrations()?; + db.ensure_model_pricing_seeded()?; Ok(db) } @@ -111,6 +112,7 @@ impl Database { conn: Mutex::new(conn), }; db.create_tables()?; + db.ensure_model_pricing_seeded()?; Ok(db) } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 58005148b..11b399ad6 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -31,12 +31,19 @@ impl Database { icon_color TEXT, meta TEXT NOT NULL DEFAULT '{}', is_current BOOLEAN NOT NULL DEFAULT 0, + is_proxy_target BOOLEAN NOT NULL DEFAULT 0, PRIMARY KEY (id, app_type) )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; + // 尝试添加 is_proxy_target 列(如果表已存在但缺少该列) + let _ = conn.execute( + "ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0", + [], + ); + // 2. Provider Endpoints 表 conn.execute( "CREATE TABLE IF NOT EXISTS provider_endpoints ( @@ -120,6 +127,215 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 8. Proxy Config 表 (代理服务器配置) + // 代理配置表(单例) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + listen_address TEXT NOT NULL DEFAULT '127.0.0.1', + listen_port INTEGER NOT NULL DEFAULT 5000, + max_retries INTEGER NOT NULL DEFAULT 3, + request_timeout INTEGER NOT NULL DEFAULT 300, + enable_logging INTEGER NOT NULL DEFAULT 1, + target_app TEXT NOT NULL DEFAULT 'claude', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 尝试添加 target_app 列(如果表已存在但缺少该列) + // 忽略 "duplicate column name" 错误 + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'", + [], + ); + + // 9. Provider Health 表 (Provider健康状态) + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_health ( + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + is_healthy INTEGER NOT NULL DEFAULT 1, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_success_at TEXT, + last_failure_at TEXT, + last_error TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (provider_id, app_type), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 10. Proxy Usage 表 (代理使用统计,可选) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + endpoint TEXT NOT NULL, + request_tokens INTEGER, + response_tokens INTEGER, + status_code INTEGER NOT NULL, + latency_ms INTEGER NOT NULL, + error TEXT, + timestamp TEXT NOT NULL + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 为 proxy_usage 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp + ON proxy_usage(timestamp)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider + ON proxy_usage(provider_id, app_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 11. Proxy Request Logs 表 (详细请求日志) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + first_token_ms INTEGER, + duration_ms INTEGER, + status_code INTEGER NOT NULL, + error_message TEXT, + session_id TEXT, + provider_type TEXT, + is_streaming INTEGER NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + created_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_provider + ON proxy_request_logs(provider_id, app_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_created_at + ON proxy_request_logs(created_at)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_model + ON proxy_request_logs(model)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_session + ON proxy_request_logs(session_id)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_status + ON proxy_request_logs(status_code)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 12. Model Pricing 表 (模型定价) + conn.execute( + "CREATE TABLE IF NOT EXISTS model_pricing ( + model_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, + output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 13. Usage Daily Stats 表 (每日聚合统计) + conn.execute( + "CREATE TABLE IF NOT EXISTS usage_daily_stats ( + date TEXT NOT NULL, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, provider_id, app_type, model) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计) + conn.execute( + "CREATE TABLE IF NOT EXISTS model_test_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + provider_name TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + prompt TEXT NOT NULL, + success INTEGER NOT NULL, + message TEXT NOT NULL, + response_time_ms INTEGER, + http_status INTEGER, + tested_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider + ON model_test_logs(provider_id, app_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at + ON model_test_logs(tested_at DESC)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } @@ -150,7 +366,12 @@ impl Database { 0 => { log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)"); Self::migrate_v0_to_v1(conn)?; - Self::set_user_version(conn, SCHEMA_VERSION)?; + Self::set_user_version(conn, 1)?; + } + 1 => { + log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)"); + Self::migrate_v1_to_v2(conn)?; + Self::set_user_version(conn, 2)?; } _ => { return Err(AppError::Database(format!( @@ -237,6 +458,250 @@ impl Database { Ok(()) } + /// v1 -> v2 迁移:添加使用统计表和完整字段 + fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { + // providers 表字段 + Self::add_column_if_missing( + conn, + "providers", + "cost_multiplier", + "TEXT NOT NULL DEFAULT '1.0'", + )?; + Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?; + Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?; + + // proxy_request_logs 表(包含所有字段) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + first_token_ms INTEGER, + duration_ms INTEGER, + status_code INTEGER NOT NULL, + error_message TEXT, + session_id TEXT, + provider_type TEXT, + is_streaming INTEGER NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + created_at INTEGER NOT NULL + )", + [], + )?; + + // 为已存在的表添加新字段 + Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?; + Self::add_column_if_missing( + conn, + "proxy_request_logs", + "is_streaming", + "INTEGER NOT NULL DEFAULT 0", + )?; + Self::add_column_if_missing( + conn, + "proxy_request_logs", + "cost_multiplier", + "TEXT NOT NULL DEFAULT '1.0'", + )?; + Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?; + Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?; + + // model_pricing 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS model_pricing ( + model_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, + output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", + [], + )?; + + // usage_daily_stats 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS usage_daily_stats ( + date TEXT NOT NULL, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, provider_id, app_type, model) + )", + [], + )?; + + // 清空并重新插入模型定价 + conn.execute("DELETE FROM model_pricing", []) + .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; + Self::seed_model_pricing(conn)?; + + Ok(()) + } + + /// 插入默认模型定价数据 + /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) + /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 + fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { + let pricing_data = [ + // Claude 4.5 系列 + ( + "claude-opus-4-5", + "Claude Opus 4.5", + "5", + "25", + "0.50", + "6.25", + ), + ( + "claude-sonnet-4-5", + "Claude Sonnet 4.5", + "3", + "15", + "0.30", + "3.75", + ), + ( + "claude-haiku-4-5", + "Claude Haiku 4.5", + "1", + "5", + "0.10", + "1.25", + ), + // Claude 4.1 系列 + ( + "claude-opus-4-1", + "Claude Opus 4.1", + "15", + "75", + "1.50", + "18.75", + ), + ( + "claude-sonnet-4-1", + "Claude Sonnet 4.1", + "3", + "15", + "0.30", + "3.75", + ), + // Claude 3.7 系列 + ( + "claude-sonnet-3-7", + "Claude Sonnet 3.7", + "3", + "15", + "0.30", + "3.75", + ), + // Claude 3.5 系列 + ( + "claude-sonnet-3-5", + "Claude Sonnet 3.5", + "3", + "15", + "0.30", + "3.75", + ), + ( + "claude-haiku-3-5", + "Claude Haiku 3.5", + "0.80", + "4", + "0.08", + "1", + ), + // GPT-5 系列(model_id 使用短横线格式) + ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"), + ("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"), + ("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"), + // Gemini 3 系列 + ( + "gemini-3-pro-preview", + "Gemini 3 Pro Preview", + "2", + "12", + "0", + "0", + ), + // Gemini 2.5 系列(model_id 使用短横线格式) + ( + "gemini-2-5-pro", + "Gemini 2.5 Pro", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gemini-2-5-flash", + "Gemini 2.5 Flash", + "0.3", + "2.5", + "0.03", + "0", + ), + ]; + + for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + model_id, + display_name, + input, + output, + cache_read, + cache_creation + ], + ) + .map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?; + } + + log::info!("已插入 {} 条默认模型定价数据", pricing_data.len()); + Ok(()) + } + + /// 确保模型定价表具备默认数据 + pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + Self::ensure_model_pricing_seeded_on_conn(&conn) + } + + fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> { + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) + .map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?; + + if count == 0 { + Self::seed_model_pricing(conn)?; + } + Ok(()) + } + // --- 辅助方法 --- pub(crate) fn get_user_version(conn: &Connection) -> Result { diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index a7684cf22..2ce719039 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() { meta: None, icon: None, icon_color: None, + is_proxy_target: Some(false), }, ); @@ -272,3 +273,62 @@ fn dry_run_validates_schema_compatibility() { "Dry-run should succeed with provider data: {result:?}" ); } + +#[test] +fn model_pricing_is_seeded_on_init() { + let db = Database::memory().expect("create memory db"); + + let conn = db.conn.lock().expect("lock conn"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) + .expect("count pricing"); + + assert!( + count > 0, + "模型定价数据应该在初始化时自动填充,实际数量: {}", + count + ); + + // 验证包含 Claude 模型 + let claude_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'", + [], + |row| row.get(0), + ) + .expect("check claude"); + assert!( + claude_count > 0, + "应该包含 Claude 模型定价,实际数量: {}", + claude_count + ); + + // 验证包含 GPT 模型 + let gpt_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'", + [], + |row| row.get(0), + ) + .expect("check gpt"); + assert!( + gpt_count > 0, + "应该包含 GPT 模型定价,实际数量: {}", + gpt_count + ); + + // 验证包含 Gemini 模型 + let gemini_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'", + [], + |row| row.get(0), + ) + .expect("check gemini"); + assert!( + gemini_count > 0, + "应该包含 Gemini 模型定价,实际数量: {}", + gemini_count + ); +} diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 7f155f4a8..3b6cd8631 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -129,6 +129,7 @@ pub(crate) fn build_provider_from_request( meta: None, icon: request.icon.clone(), icon_color: None, + is_proxy_target: None, }; Ok(provider) diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index b8f23b0bd..e9eafd35b 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -91,12 +91,27 @@ impl From> for AppError { } } +impl From for AppError { + fn from(err: rusqlite::Error) -> Self { + Self::Database(err.to_string()) + } +} + impl From for String { fn from(err: AppError) -> Self { err.to_string() } } +impl serde::Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + /// 格式化为 JSON 错误字符串,前端可解析为结构化错误 pub fn format_skill_error( code: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d139edac7..af735ecdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ mod prompt; mod prompt_files; mod provider; mod provider_defaults; +mod proxy; mod services; mod settings; mod store; @@ -521,6 +522,28 @@ pub fn run() { } } + // 自动启动代理服务器 + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let state = app_handle.state::(); + match state.db.get_proxy_config().await { + Ok(config) => { + if config.enabled { + log::info!("代理服务配置为启用,正在启动..."); + match state.proxy_service.start().await { + Ok(info) => log::info!( + "代理服务器自动启动成功: {}:{}", + info.address, + info.port + ), + Err(e) => log::error!("代理服务器自动启动失败: {e}"), + } + } + } + Err(e) => log::error!("启动时获取代理配置失败: {e}"), + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -530,6 +553,7 @@ pub fn run() { commands::update_provider, commands::delete_provider, commands::switch_provider, + commands::set_proxy_target_provider, commands::import_default_config, commands::get_claude_config_status, commands::get_config_status, @@ -619,6 +643,31 @@ pub fn run() { // Auto launch commands::set_auto_launch, commands::get_auto_launch_status, + // Proxy server management + commands::start_proxy_server, + commands::stop_proxy_server, + commands::get_proxy_status, + commands::get_proxy_config, + commands::update_proxy_config, + commands::is_proxy_running, + // Usage statistics + commands::get_usage_summary, + commands::get_usage_trends, + commands::get_provider_stats, + commands::get_model_stats, + commands::get_request_logs, + commands::get_request_detail, + commands::get_model_pricing, + commands::update_model_pricing, + commands::delete_model_pricing, + commands::check_provider_limits, + // Model testing + commands::test_provider_model, + commands::test_all_providers_model, + commands::get_model_test_config, + commands::save_model_test_config, + commands::get_model_test_logs, + commands::cleanup_model_test_logs, ]); let app = builder diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 75e01f485..7de68b706 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -36,6 +36,10 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "iconColor")] pub icon_color: Option, + /// 是否为代理目标(数据库专用字段,不写入配置文件) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "isProxyTarget")] + pub is_proxy_target: Option, } impl Provider { @@ -58,6 +62,7 @@ impl Provider { meta: None, icon: None, icon_color: None, + is_proxy_target: None, } } } @@ -151,6 +156,15 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub partner_promotion_key: Option, + /// 成本倍数(用于计算实际成本) + #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")] + pub cost_multiplier: Option, + /// 每日消费限额(USD) + #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")] + pub limit_daily_usd: Option, + /// 每月消费限额(USD) + #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")] + pub limit_monthly_usd: Option, } impl ProviderManager { diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs new file mode 100644 index 000000000..316ffdc3f --- /dev/null +++ b/src-tauri/src/proxy/error.rs @@ -0,0 +1,183 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ProxyError { + #[error("服务器已在运行")] + AlreadyRunning, + + #[error("服务器未运行")] + NotRunning, + + #[error("地址绑定失败: {0}")] + BindFailed(String), + + #[error("请求转发失败: {0}")] + ForwardFailed(String), + + #[error("无可用的Provider")] + NoAvailableProvider, + + #[allow(dead_code)] + #[error("Provider不健康: {0}")] + ProviderUnhealthy(String), + + #[error("上游错误 (状态码 {status}): {body:?}")] + UpstreamError { status: u16, body: Option }, + + #[error("超过最大重试次数")] + MaxRetriesExceeded, + + #[error("数据库错误: {0}")] + DatabaseError(String), + + #[error("配置错误: {0}")] + ConfigError(String), + + #[allow(dead_code)] + #[error("格式转换错误: {0}")] + TransformError(String), + + #[allow(dead_code)] + #[error("无效的请求: {0}")] + InvalidRequest(String), + + #[error("超时: {0}")] + Timeout(String), + + /// 流式响应空闲超时 + #[allow(dead_code)] + #[error("流式响应空闲超时: {0}秒无数据")] + StreamIdleTimeout(u64), + + /// 认证错误 + #[allow(dead_code)] + #[error("认证失败: {0}")] + AuthError(String), + + #[allow(dead_code)] + #[error("内部错误: {0}")] + Internal(String), +} + +impl IntoResponse for ProxyError { + fn into_response(self) -> Response { + let (status, body) = match &self { + ProxyError::UpstreamError { + status: upstream_status, + body: upstream_body, + } => { + let http_status = + StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY); + + // 尝试解析上游响应体为 JSON,如果失败则包装为字符串 + let error_body = if let Some(body_str) = upstream_body { + if let Ok(json_body) = serde_json::from_str::(body_str) { + // 上游返回的是 JSON,直接透传 + json_body + } else { + // 上游返回的不是 JSON,包装为错误消息 + json!({ + "error": { + "message": body_str, + "type": "upstream_error", + } + }) + } + } else { + json!({ + "error": { + "message": format!("Upstream error (status {})", upstream_status), + "type": "upstream_error", + } + }) + }; + + (http_status, error_body) + } + _ => { + let (http_status, message) = match &self { + ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()), + ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), + ProxyError::BindFailed(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()), + ProxyError::NoAvailableProvider => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::ProviderUnhealthy(_) => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::MaxRetriesExceeded => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::DatabaseError(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()), + ProxyError::TransformError(_) => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } + ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()), + ProxyError::StreamIdleTimeout(_) => { + (StatusCode::GATEWAY_TIMEOUT, self.to_string()) + } + ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()), + ProxyError::Internal(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::UpstreamError { .. } => unreachable!(), + }; + + let error_body = json!({ + "error": { + "message": message, + "type": "proxy_error", + } + }); + + (http_status, error_body) + } + }; + + (status, Json(body)).into_response() + } +} + +/// 错误分类 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCategory { + /// 可重试错误(网络问题、5xx) + Retryable, // 网络超时、5xx 错误 + /// 不可重试错误(4xx、认证失败) + NonRetryable, // 认证失败、参数错误、4xx 错误 + #[allow(dead_code)] + ClientAbort, // 客户端主动中断 +} + +/// 判断错误是否可重试 +#[allow(dead_code)] +pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory { + if error.is_timeout() || error.is_connect() { + return ErrorCategory::Retryable; + } + + if let Some(status) = error.status() { + if status.is_server_error() { + ErrorCategory::Retryable + } else if status.is_client_error() { + ErrorCategory::NonRetryable + } else { + ErrorCategory::Retryable + } + } else { + ErrorCategory::Retryable + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs new file mode 100644 index 000000000..245759d04 --- /dev/null +++ b/src-tauri/src/proxy/forwarder.rs @@ -0,0 +1,328 @@ +//! 请求转发器 +//! +//! 负责将请求转发到上游Provider,支持重试和故障转移 + +use super::{ + error::*, + providers::{get_adapter, ProviderAdapter}, + router::ProviderRouter, + types::ProxyStatus, + ProxyError, +}; +use crate::{app_config::AppType, database::Database, provider::Provider}; +use reqwest::{Client, Response}; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +pub struct RequestForwarder { + client: Client, + router: ProviderRouter, + max_retries: u8, + status: Arc>, +} + +impl RequestForwarder { + pub fn new( + db: Arc, + timeout_secs: u64, + max_retries: u8, + status: Arc>, + ) -> Self { + let mut client_builder = Client::builder(); + if timeout_secs > 0 { + client_builder = client_builder.timeout(Duration::from_secs(timeout_secs)); + } + + let client = client_builder + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + router: ProviderRouter::new(db), + max_retries, + status, + } + } + + /// 转发请求(带重试和故障转移) + pub async fn forward_with_retry( + &self, + app_type: &AppType, + endpoint: &str, + body: Value, + headers: axum::http::HeaderMap, + ) -> Result { + let mut failed_ids = Vec::new(); + let mut failover_happened = false; + + // 获取适配器 + let adapter = get_adapter(app_type); + + for attempt in 0..self.max_retries { + // 选择Provider + let provider = self.router.select_provider(app_type, &failed_ids).await?; + + log::debug!( + "尝试 {} - 使用Provider: {} ({})", + attempt + 1, + provider.name, + provider.id + ); + + // 更新状态中的当前Provider信息 + { + let mut status = self.status.write().await; + status.current_provider = Some(provider.name.clone()); + status.current_provider_id = Some(provider.id.clone()); + status.total_requests += 1; + status.last_request_at = Some(chrono::Utc::now().to_rfc3339()); + if attempt > 0 { + failover_happened = true; + } + } + + let start = Instant::now(); + + // 转发请求 + match self + .forward(&provider, endpoint, &body, &headers, adapter.as_ref()) + .await + { + Ok(response) => { + let _latency = start.elapsed().as_millis() as u64; + + // 成功:更新健康状态 + self.router + .update_health(&provider, app_type, true, None) + .await; + + // 更新成功统计 + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + if failover_happened { + status.failover_count += 1; + } + // 重新计算成功率 + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(response); + } + Err(e) => { + let latency = start.elapsed().as_millis() as u64; + + // 失败:分类错误 + let category = self.categorize_proxy_error(&e); + + match category { + ErrorCategory::Retryable => { + // 可重试:更新健康状态,添加到失败列表 + self.router + .update_health(&provider, app_type, false, Some(e.to_string())) + .await; + failed_ids.push(provider.id.clone()); + + // 更新错误信息 + { + let mut status = self.status.write().await; + status.last_error = + Some(format!("Provider {} 失败: {}", provider.name, e)); + } + + log::warn!( + "请求失败(可重试): Provider {} - {} - {}ms", + provider.name, + e, + latency + ); + continue; + } + ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { + // 不可重试:更新失败统计并返回 + { + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + log::error!("请求失败(不可重试): {e}"); + return Err(e); + } + } + } + } + } + + // 所有重试都失败 + { + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some("已达到最大重试次数".to_string()); + if status.total_requests > 0 { + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; + } + } + + Err(ProxyError::MaxRetriesExceeded) + } + + /// 转发单个请求(使用适配器) + async fn forward( + &self, + provider: &Provider, + endpoint: &str, + body: &Value, + headers: &axum::http::HeaderMap, + adapter: &dyn ProviderAdapter, + ) -> Result { + // 使用适配器提取 base_url + let base_url = adapter.extract_base_url(provider)?; + log::info!("[{}] base_url: {}", adapter.name(), base_url); + + // 使用适配器构建 URL + let url = adapter.build_url(&base_url, endpoint); + + // 检查是否需要格式转换 + let needs_transform = adapter.needs_transform(provider); + + // 记录原始请求 JSON + log::info!( + "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", + adapter.name(), + serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string()) + ); + + // 转换请求体(如果需要) + let request_body = if needs_transform { + log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); + let transformed = adapter.transform_request(body.clone(), provider)?; + log::info!( + "[{}] >>> 转换后的请求 JSON:\n{}", + adapter.name(), + serde_json::to_string_pretty(&transformed).unwrap_or_default() + ); + transformed + } else { + body.clone() + }; + + log::info!( + "[{}] 转发请求: {} -> {}", + adapter.name(), + provider.name, + url + ); + + // 构建请求 + let mut request = self.client.post(&url); + + // 只透传必要的 Headers(白名单模式) + let allowed_headers = [ + "accept", + "user-agent", + "x-request-id", + "x-stainless-arch", + "x-stainless-lang", + "x-stainless-os", + "x-stainless-package-version", + "x-stainless-runtime", + "x-stainless-runtime-version", + ]; + + for (key, value) in headers { + let key_str = key.as_str().to_lowercase(); + if allowed_headers.contains(&key_str.as_str()) { + request = request.header(key, value); + } + } + + // 确保 Content-Type 是 json + request = request.header("Content-Type", "application/json"); + + // 使用适配器添加认证头 + if let Some(auth) = adapter.extract_auth(provider) { + log::debug!( + "[{}] 使用认证: {:?} (key: {})", + adapter.name(), + auth.strategy, + auth.masked_key() + ); + request = adapter.add_auth_headers(request, &auth); + } else { + log::error!( + "[{}] 未找到 API Key!Provider: {}", + adapter.name(), + provider.name + ); + } + + // 发送请求 + log::info!("[{}] 发送请求到: {}", adapter.name(), url); + let response = request.json(&request_body).send().await.map_err(|e| { + log::error!("[{}] 请求失败: {}", adapter.name(), e); + if e.is_timeout() { + ProxyError::Timeout(format!("请求超时: {e}")) + } else if e.is_connect() { + ProxyError::ForwardFailed(format!("连接失败: {e}")) + } else { + ProxyError::ForwardFailed(e.to_string()) + } + })?; + + // 检查响应状态 + let status = response.status(); + log::info!("[{}] 响应状态: {}", adapter.name(), status); + + if status.is_success() { + Ok(response) + } else { + let status_code = status.as_u16(); + let body_text = response.text().await.ok(); + log::error!( + "[{}] 上游错误 ({}): {:?}", + adapter.name(), + status_code, + body_text + ); + + Err(ProxyError::UpstreamError { + status: status_code, + body: body_text, + }) + } + } + + /// 分类ProxyError + fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { + match error { + ProxyError::Timeout(_) => ErrorCategory::Retryable, + ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, + ProxyError::UpstreamError { status, .. } => { + if *status >= 500 { + ErrorCategory::Retryable + } else if *status >= 400 && *status < 500 { + ErrorCategory::NonRetryable + } else { + ErrorCategory::Retryable + } + } + ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, + ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, + _ => ErrorCategory::NonRetryable, + } + } +} diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs new file mode 100644 index 000000000..54ad0cce2 --- /dev/null +++ b/src-tauri/src/proxy/handlers.rs @@ -0,0 +1,1125 @@ +//! 请求处理器 +//! +//! 处理各种API端点的HTTP请求 + +use super::{ + forwarder::RequestForwarder, + providers::{get_adapter, transform, ProviderType}, + server::ProxyState, + session::ProxySession, + types::*, + usage::{logger::UsageLogger, parser::TokenUsage}, + ProxyError, +}; +use crate::app_config::AppType; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use rust_decimal::Decimal; +use serde_json::{json, Value}; +use std::{ + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +use tokio::sync::Mutex; + +/// 记录请求使用量(带 ProxySession 支持) +#[allow(dead_code, clippy::too_many_arguments)] +async fn log_usage_with_session( + state: &ProxyState, + session: &ProxySession, + provider_id: &str, + app_type: &str, + usage: TokenUsage, + latency_ms: u64, + first_token_ms: Option, + status_code: u16, + provider_type: Option<&ProviderType>, +) { + let logger = UsageLogger::new(&state.db); + + // 获取 provider 的 cost_multiplier + let multiplier = match state.db.get_provider_by_id(provider_id, app_type) { + Ok(Some(p)) => { + if let Some(meta) = p.meta { + if let Some(cm) = meta.cost_multiplier { + Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) + } else { + Decimal::from(1) + } + } else { + Decimal::from(1) + } + } + _ => Decimal::from(1), + }; + + let model = session + .model + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let provider_type_str = provider_type.map(|pt| pt.as_str().to_string()); + + if let Err(e) = logger.log_with_calculation( + session.session_id.clone(), + provider_id.to_string(), + app_type.to_string(), + model, + usage, + multiplier, + latency_ms, + first_token_ms, + status_code, + Some(session.session_id.clone()), + provider_type_str, + session.is_streaming, + ) { + log::warn!("记录使用量失败: {e}"); + } +} + +/// 记录请求使用量(兼容旧接口) +#[allow(clippy::too_many_arguments)] +async fn log_usage( + state: &ProxyState, + provider_id: &str, + app_type: &str, + model: &str, + usage: TokenUsage, + latency_ms: u64, + first_token_ms: Option, + is_streaming: bool, + status_code: u16, +) { + let logger = UsageLogger::new(&state.db); + + // 获取 provider 的 cost_multiplier + let multiplier = match state.db.get_provider_by_id(provider_id, app_type) { + Ok(Some(p)) => { + if let Some(meta) = p.meta { + if let Some(cm) = meta.cost_multiplier { + Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) + } else { + Decimal::from(1) + } + } else { + Decimal::from(1) + } + } + _ => Decimal::from(1), + }; + + let request_id = uuid::Uuid::new_v4().to_string(); + + if let Err(e) = logger.log_with_calculation( + request_id, + provider_id.to_string(), + app_type.to_string(), + model.to_string(), + usage, + multiplier, + latency_ms, + first_token_ms, + status_code, + None, + None, // provider_type + is_streaming, + ) { + log::warn!("记录使用量失败: {e}"); + } +} + +type UsageCallbackWithTiming = Arc, Option) + Send + Sync + 'static>; + +#[derive(Clone)] +struct SseUsageCollector { + inner: Arc, +} + +struct SseUsageCollectorInner { + events: Mutex>, + first_event_time: Mutex>, + start_time: std::time::Instant, + on_complete: UsageCallbackWithTiming, + finished: AtomicBool, +} + +impl SseUsageCollector { + fn new( + start_time: std::time::Instant, + callback: impl Fn(Vec, Option) + Send + Sync + 'static, + ) -> Self { + let on_complete: UsageCallbackWithTiming = Arc::new(callback); + Self { + inner: Arc::new(SseUsageCollectorInner { + events: Mutex::new(Vec::new()), + first_event_time: Mutex::new(None), + start_time, + on_complete, + finished: AtomicBool::new(false), + }), + } + } + + async fn push(&self, event: Value) { + // 记录首个事件时间 + { + let mut first_time = self.inner.first_event_time.lock().await; + if first_time.is_none() { + *first_time = Some(std::time::Instant::now()); + } + } + let mut events = self.inner.events.lock().await; + events.push(event); + } + + async fn finish(&self) { + if self.inner.finished.swap(true, Ordering::SeqCst) { + return; + } + + let events = { + let mut guard = self.inner.events.lock().await; + std::mem::take(&mut *guard) + }; + + let first_token_ms = { + let first_time = self.inner.first_event_time.lock().await; + first_time.map(|t| (t - self.inner.start_time).as_millis() as u64) + }; + + (self.inner.on_complete)(events, first_token_ms); + } +} + +/// 创建带日志记录的透传流 +fn create_logged_passthrough_stream( + stream: impl Stream> + Send + 'static, + tag: &'static str, + usage_collector: Option, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut collector = usage_collector; + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // 尝试解析并记录完整的 SSE 事件 + while let Some(pos) = buffer.find("\n\n") { + let event_text = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + if !event_text.trim().is_empty() { + // 提取 data 部分并尝试解析为 JSON + for line in event_text.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data.trim() != "[DONE]" { + if let Ok(json_value) = serde_json::from_str::(data) { + if let Some(c) = &collector { + c.push(json_value.clone()).await; + } + log::info!( + "[{}] <<< SSE 事件:\n{}", + tag, + serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) + ); + } else { + log::info!("[{tag}] <<< SSE 数据: {data}"); + } + } else { + log::info!("[{tag}] <<< SSE: [DONE]"); + } + } + } + } + } + + yield Ok(bytes); + } + Err(e) => { + log::error!("[{tag}] 流错误: {e}"); + yield Err(std::io::Error::other(e.to_string())); + break; + } + } + } + + log::info!("[{}] ====== 流结束 ======", tag); + + if let Some(c) = collector.take() { + c.finish().await; + } + } +} + +/// 健康检查 +pub async fn health_check() -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "status": "healthy", + "timestamp": chrono::Utc::now().to_rfc3339(), + })), + ) +} + +/// 获取服务状态 +pub async fn get_status(State(state): State) -> Result, ProxyError> { + let status = state.status.read().await.clone(); + Ok(Json(status)) +} + +/// 处理 /v1/messages 请求(Claude API) +pub async fn handle_messages( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let start_time = std::time::Instant::now(); + + let config = state.config.read().await.clone(); + let request_model = body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + // 选择目标 Provider + let router = super::router::ProviderRouter::new(state.db.clone()); + let failed_ids = Vec::new(); + let provider = router + .select_provider(&AppType::Claude, &failed_ids) + .await?; + + // 检查是否需要转换(OpenRouter) + let adapter = get_adapter(&AppType::Claude); + let needs_transform = adapter.needs_transform(&provider); + + // 检查是否是流式请求 + let is_stream = body + .get("stream") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + log::info!( + "[Claude] Provider: {}, needs_transform: {}, is_stream: {}", + provider.name, + needs_transform, + is_stream + ); + + let forwarder = RequestForwarder::new( + state.db.clone(), + config.request_timeout, + config.max_retries, + state.status.clone(), + ); + + let response = forwarder + .forward_with_retry(&AppType::Claude, "/v1/messages", body, headers) + .await?; + + let status = response.status(); + log::info!("[Claude] 上游响应状态: {status}"); + + // 如果需要转换 + if needs_transform { + if is_stream { + // 流式响应转换 + log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)"); + + let stream = response.bytes_stream(); + let sse_stream = super::providers::streaming::create_anthropic_sse_stream(stream); + + let usage_collector = { + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = request_model.clone(); + let status_code = status.as_u16(); + let start_time_clone = start_time; + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { + let latency_ms = start_time_clone.elapsed().as_millis() as u64; + let state = state.clone(); + let provider_id = provider_id.clone(); + let model = model.clone(); + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + first_token_ms, + true, // is_streaming + status_code, + ) + .await; + }); + } else { + log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + + let logged_stream = create_logged_passthrough_stream( + sse_stream, + "Claude/OpenRouter", + Some(usage_collector), + ); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "Content-Type", + axum::http::HeaderValue::from_static("text/event-stream"), + ); + headers.insert( + "Cache-Control", + axum::http::HeaderValue::from_static("no-cache"), + ); + headers.insert( + "Connection", + axum::http::HeaderValue::from_static("keep-alive"), + ); + + let body = axum::body::Body::from_stream(logged_stream); + log::info!("[Claude] ====== 请求结束 (流式转换) ======"); + return Ok((headers, body).into_response()); + } else { + // 非流式响应转换 + log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)"); + + let response_headers = response.headers().clone(); + + // 读取响应体 + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Claude] 读取响应体失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + let body_str = String::from_utf8_lossy(&body_bytes); + log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len()); + log::debug!("[Claude] OpenAI 原始响应: {body_str}"); + + // 解析并转换 + let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { + log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); + ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) + })?; + + log::info!("[Claude] 解析 OpenAI 响应成功"); + log::info!( + "[Claude] <<< OpenAI 响应 JSON:\n{}", + serde_json::to_string_pretty(&openai_response).unwrap_or_default() + ); + + let anthropic_response = + transform::openai_to_anthropic(openai_response).map_err(|e| { + log::error!("[Claude] 转换响应失败: {e}"); + e + })?; + + log::info!("[Claude] 转换响应成功"); + log::info!( + "[Claude] <<< Anthropic 响应 JSON:\n{}", + serde_json::to_string_pretty(&anthropic_response).unwrap_or_default() + ); + + // 记录使用量 + if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { + let model = anthropic_response + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + let latency_ms = start_time.elapsed().as_millis() as u64; + + tokio::spawn({ + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = model.to_string(); + async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } + + log::info!("[Claude] ====== 请求结束 ======"); + + // 构建响应 + let mut builder = axum::response::Response::builder().status(status); + + // 复制响应头(排除 content-length,因为内容已改变) + for (key, value) in response_headers.iter() { + if key.as_str().to_lowercase() != "content-length" + && key.as_str().to_lowercase() != "transfer-encoding" + { + builder = builder.header(key, value); + } + } + + builder = builder.header("content-type", "application/json"); + + let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { + log::error!("[Claude] 序列化响应失败: {e}"); + ProxyError::TransformError(format!("Failed to serialize response: {e}")) + })?; + + log::info!( + "[Claude] 返回转换后的响应, 长度: {} bytes", + response_body.len() + ); + + let body = axum::body::Body::from(response_body); + return Ok(builder.body(body).unwrap()); + } + } + + // 透传响应(直连 Anthropic) + log::info!("[Claude] 透传响应模式"); + + // 检查是否流式响应 + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let is_sse = content_type.contains("text/event-stream"); + + if is_sse { + // 流式透传:使用包装流记录 SSE 事件 + log::info!("[Claude] 流式透传响应 (SSE)"); + let mut builder = axum::response::Response::builder().status(status); + + for (key, value) in response.headers() { + builder = builder.header(key, value); + } + + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); + let usage_collector = { + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = request_model.clone(); + let status_code = status.as_u16(); + let start_time_clone = start_time; + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { + let latency_ms = start_time_clone.elapsed().as_millis() as u64; + let state = state.clone(); + let provider_id = provider_id.clone(); + let model = model.clone(); + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + first_token_ms, + true, + status_code, + ) + .await; + }); + } else { + log::debug!("[Claude] 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + let logged_stream = + create_logged_passthrough_stream(stream, "Claude", Some(usage_collector)); + + let body = axum::body::Body::from_stream(logged_stream); + log::info!("[Claude] ====== 请求结束 (流式) ======"); + Ok(builder.body(body).unwrap()) + } else { + // 非流式透传:读取完整响应并记录 + let response_headers = response.headers().clone(); + let status = response.status(); + + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Claude] 读取透传响应失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + // 记录响应 JSON + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + log::info!( + "[Claude] <<< Anthropic 透传响应 JSON:\n{}", + serde_json::to_string_pretty(&json_value).unwrap_or_default() + ); + + // 记录使用量 + if let Some(usage) = TokenUsage::from_claude_response(&json_value) { + let model = json_value + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + let latency_ms = start_time.elapsed().as_millis() as u64; + + tokio::spawn({ + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = model.to_string(); + async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } + } else { + log::info!( + "[Claude] <<< 透传响应 (非 JSON): {} bytes", + body_bytes.len() + ); + } + log::info!("[Claude] ====== 请求结束 ======"); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + + let body = axum::body::Body::from(body_bytes); + Ok(builder.body(body).unwrap()) + } +} + +/// 处理 Gemini API 请求(透传,包括查询参数) +pub async fn handle_gemini( + State(state): State, + uri: axum::http::Uri, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let start_time = std::time::Instant::now(); + + let config = state.config.read().await.clone(); + + // 选择目标 Provider + let router = super::router::ProviderRouter::new(state.db.clone()); + let failed_ids = Vec::new(); + let provider = router + .select_provider(&AppType::Gemini, &failed_ids) + .await?; + + let forwarder = RequestForwarder::new( + state.db.clone(), + config.request_timeout, + config.max_retries, + state.status.clone(), + ); + + // 提取完整的路径和查询参数 + let endpoint = uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or(uri.path()); + let gemini_model = endpoint + .split('/') + .find(|s| s.starts_with("models/")) + .and_then(|s| s.strip_prefix("models/")) + .map(|s| s.split(':').next().unwrap_or(s)) + .unwrap_or("unknown") + .to_string(); + + log::info!("[Gemini] 请求端点: {endpoint}"); + + let response = forwarder + .forward_with_retry(&AppType::Gemini, endpoint, body, headers) + .await?; + + let status = response.status(); + log::info!("[Gemini] 上游响应状态: {status}"); + + // 检查是否流式响应 + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let is_sse = content_type.contains("text/event-stream"); + + if is_sse { + // 流式透传 + log::info!("[Gemini] 流式透传响应 (SSE)"); + let mut builder = axum::response::Response::builder().status(status); + + for (key, value) in response.headers() { + builder = builder.header(key, value); + } + + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); + let usage_collector = { + let state = state.clone(); + let provider_id = provider.id.clone(); + let fallback_model = gemini_model.clone(); + let status_code = status.as_u16(); + let start_time_clone = start_time; + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_gemini_stream_chunks(&events) { + // 优先使用响应中的实际模型名称,否则使用从 URI 提取的模型名称 + let model = usage + .model + .clone() + .unwrap_or_else(|| fallback_model.clone()); + let latency_ms = start_time_clone.elapsed().as_millis() as u64; + let state = state.clone(); + let provider_id = provider_id.clone(); + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "gemini", + &model, + usage, + latency_ms, + first_token_ms, + true, + status_code, + ) + .await; + }); + } else { + log::debug!("[Gemini] 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + let logged_stream = + create_logged_passthrough_stream(stream, "Gemini", Some(usage_collector)); + + let body = axum::body::Body::from_stream(logged_stream); + Ok(builder.body(body).unwrap()) + } else { + // 非流式透传 + let response_headers = response.headers().clone(); + let status = response.status(); + + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Gemini] 读取响应失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + // 记录响应 JSON + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + log::info!( + "[Gemini] <<< 响应 JSON:\n{}", + serde_json::to_string_pretty(&json_value).unwrap_or_default() + ); + + // 记录使用量 + if let Some(usage) = TokenUsage::from_gemini_response(&json_value) { + // 优先使用响应中的实际模型名称,否则使用从 URI 提取的模型名称 + let model = usage.model.clone().unwrap_or_else(|| gemini_model.clone()); + let latency_ms = start_time.elapsed().as_millis() as u64; + tokio::spawn({ + let state = state.clone(); + let provider_id = provider.id.clone(); + async move { + log_usage( + &state, + &provider_id, + "gemini", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } + } else { + log::info!("[Gemini] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); + } + log::info!("[Gemini] ====== 请求结束 ======"); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + + let body = axum::body::Body::from(body_bytes); + Ok(builder.body(body).unwrap()) + } +} + +/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) +pub async fn handle_responses( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let start_time = std::time::Instant::now(); + + let config = state.config.read().await.clone(); + let request_model = body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + // 选择目标 Provider + let router = super::router::ProviderRouter::new(state.db.clone()); + let failed_ids = Vec::new(); + let provider = router.select_provider(&AppType::Codex, &failed_ids).await?; + + let forwarder = RequestForwarder::new( + state.db.clone(), + config.request_timeout, + config.max_retries, + state.status.clone(), + ); + + let response = forwarder + .forward_with_retry(&AppType::Codex, "/v1/responses", body, headers) + .await?; + + let status = response.status(); + log::info!("[Codex] 上游响应状态: {status}"); + + // 检查是否流式响应 + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let is_sse = content_type.contains("text/event-stream"); + + if is_sse { + // 流式透传 + log::info!("[Codex] 流式透传响应 (SSE)"); + let mut builder = axum::response::Response::builder().status(status); + + for (key, value) in response.headers() { + builder = builder.header(key, value); + } + + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); + let usage_collector = { + let state = state.clone(); + let provider_id = provider.id.clone(); + let request_model = request_model.clone(); + let status_code = status.as_u16(); + let start_time_clone = start_time; + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_codex_stream_events(&events) { + // 尝试从事件中提取模型,回退到请求模型 + let model = events + .iter() + .find_map(|e| { + if e.get("type")?.as_str()? == "response.completed" { + e.get("response")?.get("model")?.as_str() + } else { + None + } + }) + .unwrap_or(&request_model) + .to_string(); + let latency_ms = start_time_clone.elapsed().as_millis() as u64; + + let state = state.clone(); + let provider_id = provider_id.clone(); + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "codex", + &model, + usage, + latency_ms, + first_token_ms, + true, + status_code, + ) + .await; + }); + } else { + log::debug!("[Codex] 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + let logged_stream = + create_logged_passthrough_stream(stream, "Codex", Some(usage_collector)); + + let body = axum::body::Body::from_stream(logged_stream); + Ok(builder.body(body).unwrap()) + } else { + // 非流式透传 + let response_headers = response.headers().clone(); + let status = response.status(); + + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Codex] 读取响应失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + // 记录响应 JSON + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + log::info!( + "[Codex] <<< 响应 JSON:\n{}", + serde_json::to_string_pretty(&json_value).unwrap_or_default() + ); + + // 记录使用量 + if let Some(usage) = TokenUsage::from_codex_response(&json_value) { + let model = json_value + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + let latency_ms = start_time.elapsed().as_millis() as u64; + + log::info!( + "[Codex] 解析到 usage: input={}, output={}", + usage.input_tokens, + usage.output_tokens + ); + + tokio::spawn({ + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = model.to_string(); + async move { + log_usage( + &state, + &provider_id, + "codex", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } else { + log::warn!("[Codex] 未能解析 usage 信息,跳过记录"); + } + } else { + log::info!("[Codex] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); + } + log::info!("[Codex] ====== 请求结束 ======"); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + + let body = axum::body::Body::from(body_bytes); + Ok(builder.body(body).unwrap()) + } +} + +/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI) +pub async fn handle_chat_completions( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let start_time = std::time::Instant::now(); + log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); + + let config = state.config.read().await.clone(); + let request_model = body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + let is_stream = body + .get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + log::info!("[Codex] 请求模型: {request_model}, 流式: {is_stream}"); + + // 选择目标 Provider + let router = super::router::ProviderRouter::new(state.db.clone()); + let failed_ids = Vec::new(); + let provider = router.select_provider(&AppType::Codex, &failed_ids).await?; + + log::info!("[Codex] 选择 Provider: {}", provider.id); + + let forwarder = RequestForwarder::new( + state.db.clone(), + config.request_timeout, + config.max_retries, + state.status.clone(), + ); + + let response = forwarder + .forward_with_retry(&AppType::Codex, "/v1/chat/completions", body, headers) + .await?; + + let status = response.status(); + log::info!("[Codex] 上游响应状态: {status}"); + + // 检查是否流式响应 + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let is_sse = content_type.contains("text/event-stream"); + + if is_sse { + // 流式透传 + log::info!("[Codex] 流式透传响应 (SSE)"); + let mut builder = axum::response::Response::builder().status(status); + + for (key, value) in response.headers() { + builder = builder.header(key, value); + } + + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); + + let usage_collector = { + let state = state.clone(); + let provider_id = provider.id.clone(); + let request_model = request_model.clone(); + let status_code = status.as_u16(); + let start_time_clone = start_time; + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_openai_stream_events(&events) { + let model = events + .iter() + .find_map(|e| e.get("model")?.as_str()) + .unwrap_or(&request_model) + .to_string(); + let latency_ms = start_time_clone.elapsed().as_millis() as u64; + + let state = state.clone(); + let provider_id = provider_id.clone(); + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "codex", + &model, + usage, + latency_ms, + first_token_ms, + true, + status_code, + ) + .await; + }); + } else { + log::debug!("[Codex] 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + let logged_stream = + create_logged_passthrough_stream(stream, "Codex", Some(usage_collector)); + + let body = axum::body::Body::from_stream(logged_stream); + Ok(builder.body(body).unwrap()) + } else { + // 非流式透传 + let response_headers = response.headers().clone(); + let status = response.status(); + + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Codex] 读取响应失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + // 记录响应 JSON + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + log::info!( + "[Codex] <<< 响应 JSON:\n{}", + serde_json::to_string_pretty(&json_value).unwrap_or_default() + ); + + // 记录使用量 (OpenAI 格式: prompt_tokens, completion_tokens) + if let Some(usage) = TokenUsage::from_openai_response(&json_value) { + let model = json_value + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + let latency_ms = start_time.elapsed().as_millis() as u64; + + log::info!( + "[Codex] 解析到 usage: input={}, output={}", + usage.input_tokens, + usage.output_tokens + ); + + tokio::spawn({ + let state = state.clone(); + let provider_id = provider.id.clone(); + let model = model.to_string(); + async move { + log_usage( + &state, + &provider_id, + "codex", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } else { + log::warn!("[Codex] 未能解析 usage 信息,跳过记录"); + } + } else { + log::info!("[Codex] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); + } + log::info!("[Codex] ====== 请求结束 ======"); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + + let body = axum::body::Body::from(body_bytes); + Ok(builder.body(body).unwrap()) + } +} diff --git a/src-tauri/src/proxy/health.rs b/src-tauri/src/proxy/health.rs new file mode 100644 index 000000000..4cdea8e21 --- /dev/null +++ b/src-tauri/src/proxy/health.rs @@ -0,0 +1,7 @@ +//! 健康检查器 +//! +//! 负责定期检查Provider健康状态(占位实现) + +// 占位实现,稍后添加完整逻辑 +#[allow(dead_code)] +pub struct HealthChecker; diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs new file mode 100644 index 000000000..e8390252d --- /dev/null +++ b/src-tauri/src/proxy/mod.rs @@ -0,0 +1,30 @@ +//! 代理服务器模块 +//! +//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 + +pub mod error; +mod forwarder; +mod handlers; +mod health; +pub mod providers; +pub mod response_handler; +mod router; +pub(crate) mod server; +pub mod session; +pub(crate) mod types; +pub mod usage; + +// 公开导出给外部使用(commands, services等模块需要) +#[allow(unused_imports)] +pub use error::ProxyError; +#[allow(unused_imports)] +pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; +#[allow(unused_imports)] +pub use session::{ClientFormat, ProxySession}; +#[allow(unused_imports)] +pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; + +// 内部模块间共享(供子模块使用) +// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用 +#[allow(unused_imports)] +pub(crate) use types::*; diff --git a/src-tauri/src/proxy/providers/adapter.rs b/src-tauri/src/proxy/providers/adapter.rs new file mode 100644 index 000000000..c0372e03f --- /dev/null +++ b/src-tauri/src/proxy/providers/adapter.rs @@ -0,0 +1,131 @@ +//! Provider Adapter Trait +//! +//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。 + +use super::auth::AuthInfo; +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use reqwest::RequestBuilder; +use serde_json::Value; + +/// 供应商适配器 Trait +/// +/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理: +/// - URL 构建 +/// - 认证信息提取和头部注入 +/// - 请求/响应格式转换(可选) +/// +/// # 示例 +/// +/// ```ignore +/// pub struct ClaudeAdapter; +/// +/// impl ProviderAdapter for ClaudeAdapter { +/// fn name(&self) -> &'static str { "Claude" } +/// +/// fn extract_base_url(&self, provider: &Provider) -> Result { +/// // 从 provider 配置中提取 base_url +/// } +/// +/// fn extract_auth(&self, provider: &Provider) -> Option { +/// // 从 provider 配置中提取认证信息 +/// } +/// +/// fn build_url(&self, base_url: &str, endpoint: &str) -> String { +/// format!("{}{}", base_url.trim_end_matches('/'), endpoint) +/// } +/// +/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { +/// // 添加认证头 +/// } +/// } +/// ``` +pub trait ProviderAdapter: Send + Sync { + /// 适配器名称(用于日志和调试) + fn name(&self) -> &'static str; + + /// 从 Provider 配置中提取 base_url + /// + /// # Arguments + /// * `provider` - Provider 配置 + /// + /// # Returns + /// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠) + /// * `Err(ProxyError)` - 提取失败 + fn extract_base_url(&self, provider: &Provider) -> Result; + + /// 从 Provider 配置中提取认证信息 + /// + /// # Arguments + /// * `provider` - Provider 配置 + /// + /// # Returns + /// * `Some(AuthInfo)` - 提取到的认证信息 + /// * `None` - 未找到认证信息 + fn extract_auth(&self, provider: &Provider) -> Option; + + /// 构建请求 URL + /// + /// # Arguments + /// * `base_url` - 基础 URL + /// * `endpoint` - 请求端点(如 `/v1/messages`) + /// + /// # Returns + /// 完整的请求 URL + fn build_url(&self, base_url: &str, endpoint: &str) -> String; + + /// 添加认证头到请求 + /// + /// # Arguments + /// * `request` - reqwest RequestBuilder + /// * `auth` - 认证信息 + /// + /// # Returns + /// 添加了认证头的 RequestBuilder + fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder; + + /// 是否需要格式转换 + /// + /// 默认返回 `false`(透传模式)。 + /// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。 + /// + /// # Arguments + /// * `provider` - Provider 配置 + fn needs_transform(&self, _provider: &Provider) -> bool { + false + } + + /// 转换请求体 + /// + /// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。 + /// 默认实现直接返回原始请求体(透传)。 + /// + /// # Arguments + /// * `body` - 原始请求体 + /// * `provider` - Provider 配置(用于获取模型映射等) + /// + /// # Returns + /// * `Ok(Value)` - 转换后的请求体 + /// * `Err(ProxyError)` - 转换失败 + fn transform_request(&self, body: Value, _provider: &Provider) -> Result { + Ok(body) + } + + /// 转换响应体 + /// + /// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。 + /// 默认实现直接返回原始响应体(透传)。 + /// + /// # Arguments + /// * `body` - 原始响应体 + /// + /// # Returns + /// * `Ok(Value)` - 转换后的响应体 + /// * `Err(ProxyError)` - 转换失败 + /// + /// Note: 响应转换将在 handler 层集成,目前预留接口 + #[allow(dead_code)] + fn transform_response(&self, body: Value) -> Result { + Ok(body) + } +} diff --git a/src-tauri/src/proxy/providers/auth.rs b/src-tauri/src/proxy/providers/auth.rs new file mode 100644 index 000000000..7e161fbf2 --- /dev/null +++ b/src-tauri/src/proxy/providers/auth.rs @@ -0,0 +1,210 @@ +//! Authentication Types +//! +//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。 + +/// 认证信息 +/// +/// 包含 API Key 和对应的认证策略 +#[derive(Debug, Clone)] +pub struct AuthInfo { + /// API Key + pub api_key: String, + /// 认证策略 + pub strategy: AuthStrategy, + /// OAuth access_token(用于 GoogleOAuth 策略) + pub access_token: Option, +} + +impl AuthInfo { + /// 创建新的认证信息 + pub fn new(api_key: String, strategy: AuthStrategy) -> Self { + Self { + api_key, + strategy, + access_token: None, + } + } + + /// 创建带有 access_token 的认证信息(用于 OAuth) + pub fn with_access_token(api_key: String, access_token: String) -> Self { + Self { + api_key, + strategy: AuthStrategy::GoogleOAuth, + access_token: Some(access_token), + } + } + + /// 返回遮蔽后的 API Key(用于日志输出) + /// + /// 显示前4位和后4位,中间用 `...` 代替 + /// 如果 key 长度不足8位,则返回 `***` + pub fn masked_key(&self) -> String { + if self.api_key.len() > 8 { + format!( + "{}...{}", + &self.api_key[..4], + &self.api_key[self.api_key.len() - 4..] + ) + } else { + "***".to_string() + } + } + + /// 返回遮蔽后的 access_token(用于日志输出) + #[allow(dead_code)] + pub fn masked_access_token(&self) -> Option { + self.access_token.as_ref().map(|token| { + if token.len() > 8 { + format!("{}...{}", &token[..4], &token[token.len() - 4..]) + } else { + "***".to_string() + } + }) + } +} + +/// 认证策略 +/// +/// 不同供应商使用不同的认证方式 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthStrategy { + /// Anthropic 认证方式 + /// - Header: `x-api-key: ` + /// - Header: `anthropic-version: 2023-06-01` + Anthropic, + + /// Claude 中转服务认证方式(仅 Bearer,无 x-api-key) + /// + /// - Header: `Authorization: Bearer ` + /// + /// 用于不支持 x-api-key 的中转服务 + ClaudeAuth, + + /// Bearer Token 认证方式(OpenAI 等) + /// + /// - Header: `Authorization: Bearer ` + Bearer, + + /// Google API Key 认证方式 + /// + /// - Header: `x-goog-api-key: ` + Google, + + /// Google OAuth 认证方式 + /// + /// - Header: `Authorization: Bearer ` + /// + /// 用于 Gemini CLI 等需要 OAuth 的场景 + GoogleOAuth, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_masked_key_long() { + let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer); + assert_eq!(auth.masked_key(), "sk-1...cdef"); + } + + #[test] + fn test_masked_key_short() { + let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer); + assert_eq!(auth.masked_key(), "***"); + } + + #[test] + fn test_masked_key_exactly_8() { + let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer); + assert_eq!(auth.masked_key(), "***"); + } + + #[test] + fn test_masked_key_9_chars() { + let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer); + assert_eq!(auth.masked_key(), "1234...6789"); + } + + #[test] + fn test_auth_strategy_equality() { + assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic); + assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer); + assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google); + } + + #[test] + fn test_auth_info_new_has_no_access_token() { + let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer); + assert!(auth.access_token.is_none()); + } + + #[test] + fn test_auth_info_with_access_token() { + let auth = AuthInfo::with_access_token( + "refresh-token".to_string(), + "ya29.access-token-12345".to_string(), + ); + assert_eq!(auth.api_key, "refresh-token"); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + assert_eq!( + auth.access_token, + Some("ya29.access-token-12345".to_string()) + ); + } + + #[test] + fn test_masked_access_token_long() { + let auth = + AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string()); + assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string())); + } + + #[test] + fn test_masked_access_token_short() { + let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string()); + assert_eq!(auth.masked_access_token(), Some("***".to_string())); + } + + #[test] + fn test_masked_access_token_none() { + let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer); + assert!(auth.masked_access_token().is_none()); + } + + #[test] + fn test_claude_auth_strategy() { + let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth); + assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); + assert_ne!(auth.strategy, AuthStrategy::Anthropic); + assert_ne!(auth.strategy, AuthStrategy::Bearer); + } + + #[test] + fn test_google_oauth_strategy() { + let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + assert_ne!(auth.strategy, AuthStrategy::Google); + } + + #[test] + fn test_all_strategies_are_distinct() { + let strategies = [ + AuthStrategy::Anthropic, + AuthStrategy::ClaudeAuth, + AuthStrategy::Bearer, + AuthStrategy::Google, + AuthStrategy::GoogleOAuth, + ]; + + for (i, s1) in strategies.iter().enumerate() { + for (j, s2) in strategies.iter().enumerate() { + if i == j { + assert_eq!(s1, s2); + } else { + assert_ne!(s1, s2); + } + } + } + } +} diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs new file mode 100644 index 000000000..f54321ca5 --- /dev/null +++ b/src-tauri/src/proxy/providers/claude.rs @@ -0,0 +1,403 @@ +//! Claude (Anthropic) Provider Adapter +//! +//! 支持透传模式和 OpenRouter 兼容模式 +//! +//! ## 认证模式 +//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) +//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) +//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换 + +use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use reqwest::RequestBuilder; + +/// Claude 适配器 +pub struct ClaudeAdapter; + +impl ClaudeAdapter { + pub fn new() -> Self { + Self + } + + /// 获取供应商类型 + /// + /// 根据 base_url 和 auth_mode 检测具体的供应商类型: + /// - OpenRouter: base_url 包含 openrouter.ai + /// - ClaudeAuth: auth_mode 为 bearer_only + /// - Claude: 默认 Anthropic 官方 + pub fn provider_type(&self, provider: &Provider) -> ProviderType { + // 检测 OpenRouter + if let Ok(base_url) = self.extract_base_url(provider) { + if base_url.contains("openrouter.ai") { + return ProviderType::OpenRouter; + } + } + + // 检测 ClaudeAuth (仅 Bearer 认证) + if self.is_bearer_only_mode(provider) { + return ProviderType::ClaudeAuth; + } + + ProviderType::Claude + } + + /// 检测是否使用 OpenRouter + fn is_openrouter(&self, provider: &Provider) -> bool { + if let Ok(base_url) = self.extract_base_url(provider) { + return base_url.contains("openrouter.ai"); + } + false + } + + /// 检测是否为仅 Bearer 认证模式 + fn is_bearer_only_mode(&self, provider: &Provider) -> bool { + // 检查 settings_config 中的 auth_mode + if let Some(auth_mode) = provider + .settings_config + .get("auth_mode") + .and_then(|v| v.as_str()) + { + if auth_mode == "bearer_only" { + return true; + } + } + + // 检查 env 中的 AUTH_MODE + if let Some(env) = provider.settings_config.get("env") { + if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) { + if auth_mode == "bearer_only" { + return true; + } + } + } + + false + } + + /// 从 Provider 配置中提取 API Key + fn extract_key(&self, provider: &Provider) -> Option { + if let Some(env) = provider.settings_config.get("env") { + // Anthropic 标准 key + if let Some(key) = env + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); + return Some(key.to_string()); + } + // OpenRouter key + if let Some(key) = env + .get("OPENROUTER_API_KEY") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 OPENROUTER_API_KEY"); + return Some(key.to_string()); + } + // 备选 OpenAI key (用于 OpenRouter) + if let Some(key) = env + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 OPENAI_API_KEY"); + return Some(key.to_string()); + } + } + + // 尝试直接获取 + if let Some(key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + log::debug!("[Claude] 使用 apiKey/api_key"); + return Some(key.to_string()); + } + + log::warn!("[Claude] 未找到有效的 API Key"); + None + } +} + +impl Default for ClaudeAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ProviderAdapter for ClaudeAdapter { + fn name(&self) -> &'static str { + "Claude" + } + + fn extract_base_url(&self, provider: &Provider) -> Result { + // 1. 从 env 中获取 + if let Some(env) = provider.settings_config.get("env") { + if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { + return Ok(url.trim_end_matches('/').to_string()); + } + } + + // 2. 尝试直接获取 + if let Some(url) = provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + if let Some(url) = provider + .settings_config + .get("baseURL") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + if let Some(url) = provider + .settings_config + .get("apiEndpoint") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + Err(ProxyError::ConfigError( + "Claude Provider 缺少 base_url 配置".to_string(), + )) + } + + fn extract_auth(&self, provider: &Provider) -> Option { + let provider_type = self.provider_type(provider); + let strategy = match provider_type { + ProviderType::OpenRouter => AuthStrategy::Bearer, + ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, + _ => AuthStrategy::Anthropic, + }; + + self.extract_key(provider) + .map(|key| AuthInfo::new(key, strategy)) + } + + fn build_url(&self, base_url: &str, endpoint: &str) -> String { + // OpenRouter 使用 /v1/chat/completions + if base_url.contains("openrouter.ai") { + return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + } + + // Anthropic 直连 + format!( + "{}/{}", + base_url.trim_end_matches('/'), + endpoint.trim_start_matches('/') + ) + } + + fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { + match auth.strategy { + // Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version + AuthStrategy::Anthropic => request + .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("x-api-key", &auth.api_key) + .header("anthropic-version", "2023-06-01"), + // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key + AuthStrategy::ClaudeAuth => { + request.header("Authorization", format!("Bearer {}", auth.api_key)) + } + // OpenRouter: Bearer + AuthStrategy::Bearer => { + request.header("Authorization", format!("Bearer {}", auth.api_key)) + } + _ => request, + } + } + + fn needs_transform(&self, provider: &Provider) -> bool { + self.is_openrouter(provider) + } + + fn transform_request( + &self, + body: serde_json::Value, + provider: &Provider, + ) -> Result { + super::transform::anthropic_to_openai(body, provider) + } + + fn transform_response(&self, body: serde_json::Value) -> Result { + super::transform::openai_to_anthropic(body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider(config: serde_json::Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Claude".to_string(), + settings_config: config, + website_url: None, + category: Some("claude".to_string()), + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + #[test] + fn test_extract_base_url_from_env() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })); + + let url = adapter.extract_base_url(&provider).unwrap(); + assert_eq!(url, "https://api.anthropic.com"); + } + + #[test] + fn test_extract_auth_anthropic() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-ant-test-key"); + assert_eq!(auth.strategy, AuthStrategy::Anthropic); + } + + #[test] + fn test_extract_auth_openrouter() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "OPENROUTER_API_KEY": "sk-or-test-key" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-or-test-key"); + assert_eq!(auth.strategy, AuthStrategy::Bearer); + } + + #[test] + fn test_extract_auth_claude_auth_mode() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://some-proxy.com", + "ANTHROPIC_AUTH_TOKEN": "sk-proxy-key" + }, + "auth_mode": "bearer_only" + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-proxy-key"); + assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); + } + + #[test] + fn test_extract_auth_claude_auth_env_mode() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://some-proxy.com", + "ANTHROPIC_AUTH_TOKEN": "sk-proxy-key", + "AUTH_MODE": "bearer_only" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-proxy-key"); + assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); + } + + #[test] + fn test_provider_type_detection() { + let adapter = ClaudeAdapter::new(); + + // Anthropic 官方 + let anthropic = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-ant-test" + } + })); + assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude); + + // OpenRouter + let openrouter = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "OPENROUTER_API_KEY": "sk-or-test" + } + })); + assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter); + + // ClaudeAuth + let claude_auth = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://some-proxy.com", + "ANTHROPIC_AUTH_TOKEN": "sk-test" + }, + "auth_mode": "bearer_only" + })); + assert_eq!( + adapter.provider_type(&claude_auth), + ProviderType::ClaudeAuth + ); + } + + #[test] + fn test_build_url_anthropic() { + let adapter = ClaudeAdapter::new(); + let url = adapter.build_url("https://api.anthropic.com", "/v1/messages"); + assert_eq!(url, "https://api.anthropic.com/v1/messages"); + } + + #[test] + fn test_build_url_openrouter() { + let adapter = ClaudeAdapter::new(); + let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); + assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions"); + } + + #[test] + fn test_needs_transform() { + let adapter = ClaudeAdapter::new(); + + let anthropic_provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })); + assert!(!adapter.needs_transform(&anthropic_provider)); + + let openrouter_provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + } + })); + assert!(adapter.needs_transform(&openrouter_provider)); + } +} diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs new file mode 100644 index 000000000..a7599b526 --- /dev/null +++ b/src-tauri/src/proxy/providers/codex.rs @@ -0,0 +1,265 @@ +//! Codex (OpenAI) Provider Adapter +//! +//! 仅透传模式,支持直连 OpenAI API +//! +//! ## 客户端检测 +//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs) + +use super::{AuthInfo, AuthStrategy, ProviderAdapter}; +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use regex::Regex; +use reqwest::RequestBuilder; +use std::sync::LazyLock; + +/// 官方 Codex 客户端 User-Agent 正则 +#[allow(dead_code)] +static CODEX_CLIENT_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap()); + +/// Codex 适配器 +pub struct CodexAdapter; + +impl CodexAdapter { + pub fn new() -> Self { + Self + } + + /// 检测是否为官方 Codex 客户端 + /// + /// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+` + #[allow(dead_code)] + pub fn is_official_client(user_agent: &str) -> bool { + CODEX_CLIENT_REGEX.is_match(user_agent) + } + + /// 从 Provider 配置中提取 API Key + fn extract_key(&self, provider: &Provider) -> Option { + // 1. 尝试从 env 中获取 + if let Some(env) = provider.settings_config.get("env") { + if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + } + + // 2. 尝试从 auth 中获取 (Codex CLI 格式) + if let Some(auth) = provider.settings_config.get("auth") { + if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + } + + // 3. 尝试直接获取 + if let Some(key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + return Some(key.to_string()); + } + + // 4. 尝试从 config 对象中获取 + if let Some(config) = provider.settings_config.get("config") { + if let Some(key) = config + .get("api_key") + .or_else(|| config.get("apiKey")) + .and_then(|v| v.as_str()) + { + return Some(key.to_string()); + } + } + + None + } +} + +impl Default for CodexAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ProviderAdapter for CodexAdapter { + fn name(&self) -> &'static str { + "Codex" + } + + fn extract_base_url(&self, provider: &Provider) -> Result { + // 1. 尝试直接获取 base_url 字段 + if let Some(url) = provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 2. 尝试 baseURL + if let Some(url) = provider + .settings_config + .get("baseURL") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 3. 尝试从 config 对象中获取 + if let Some(config) = provider.settings_config.get("config") { + if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 尝试解析 TOML 字符串格式 + if let Some(config_str) = config.as_str() { + if let Some(start) = config_str.find("base_url = \"") { + let rest = &config_str[start + 12..]; + if let Some(end) = rest.find('"') { + return Ok(rest[..end].trim_end_matches('/').to_string()); + } + } + if let Some(start) = config_str.find("base_url = '") { + let rest = &config_str[start + 12..]; + if let Some(end) = rest.find('\'') { + return Ok(rest[..end].trim_end_matches('/').to_string()); + } + } + } + } + + Err(ProxyError::ConfigError( + "Codex Provider 缺少 base_url 配置".to_string(), + )) + } + + fn extract_auth(&self, provider: &Provider) -> Option { + self.extract_key(provider) + .map(|key| AuthInfo::new(key, AuthStrategy::Bearer)) + } + + fn build_url(&self, base_url: &str, endpoint: &str) -> String { + let base_trimmed = base_url.trim_end_matches('/'); + let endpoint_trimmed = endpoint.trim_start_matches('/'); + + let mut url = format!("{base_trimmed}/{endpoint_trimmed}"); + + // 去除重复的 /v1/v1 + if url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + + url + } + + fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { + request.header("Authorization", format!("Bearer {}", auth.api_key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider(config: serde_json::Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Codex".to_string(), + settings_config: config, + website_url: None, + category: Some("codex".to_string()), + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + #[test] + fn test_extract_base_url_direct() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "base_url": "https://api.openai.com/v1" + })); + + let url = adapter.extract_base_url(&provider).unwrap(); + assert_eq!(url, "https://api.openai.com/v1"); + } + + #[test] + fn test_extract_auth_from_auth_field() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "auth": { + "OPENAI_API_KEY": "sk-test-key-12345678" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-test-key-12345678"); + assert_eq!(auth.strategy, AuthStrategy::Bearer); + } + + #[test] + fn test_extract_auth_from_env() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "env": { + "OPENAI_API_KEY": "sk-env-key-12345678" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-env-key-12345678"); + } + + #[test] + fn test_build_url() { + let adapter = CodexAdapter::new(); + let url = adapter.build_url("https://api.openai.com/v1", "/responses"); + assert_eq!(url, "https://api.openai.com/v1/responses"); + } + + #[test] + fn test_build_url_dedup_v1() { + let adapter = CodexAdapter::new(); + // base_url 已包含 /v1,endpoint 也包含 /v1 + let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses"); + assert_eq!(url, "https://www.packyapi.com/v1/responses"); + } + + // 官方客户端检测测试 + #[test] + fn test_is_official_client_vscode() { + assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0")); + assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4")); + assert!(CodexAdapter::is_official_client("codex_vscode/0.1")); + } + + #[test] + fn test_is_official_client_cli() { + assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0")); + assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2")); + } + + #[test] + fn test_is_not_official_client() { + assert!(!CodexAdapter::is_official_client("Mozilla/5.0")); + assert!(!CodexAdapter::is_official_client("curl/7.68.0")); + assert!(!CodexAdapter::is_official_client("python-requests/2.25.1")); + assert!(!CodexAdapter::is_official_client("codex_other/1.0.0")); + assert!(!CodexAdapter::is_official_client("")); + } + + #[test] + fn test_is_official_client_partial_match() { + // 必须从开头匹配 + assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0")); + assert!(!CodexAdapter::is_official_client( + "prefix_codex_cli_rs/1.0.0" + )); + } +} diff --git a/src-tauri/src/proxy/providers/gemini.rs b/src-tauri/src/proxy/providers/gemini.rs new file mode 100644 index 000000000..b148f5a48 --- /dev/null +++ b/src-tauri/src/proxy/providers/gemini.rs @@ -0,0 +1,426 @@ +//! Gemini (Google) Provider Adapter +//! +//! 支持 API Key 和 OAuth 两种认证方式 +//! +//! ## 认证模式 +//! - **Gemini**: API Key 认证 (x-goog-api-key) +//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI) + +use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use reqwest::RequestBuilder; + +/// Gemini 适配器 +pub struct GeminiAdapter; + +/// OAuth 凭证结构 +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct OAuthCredentials { + pub access_token: String, + pub refresh_token: Option, + pub client_id: Option, + pub client_secret: Option, +} + +#[allow(dead_code)] +impl OAuthCredentials { + /// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token) + pub fn needs_refresh(&self) -> bool { + self.refresh_token.is_some() && self.access_token.is_empty() + } + + /// 检查是否可以刷新 token + pub fn can_refresh(&self) -> bool { + self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some() + } +} + +impl GeminiAdapter { + pub fn new() -> Self { + Self + } + + /// 获取供应商类型 + /// + /// 根据 API Key 格式检测: + /// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证 + /// - Gemini: 普通 API Key + pub fn provider_type(&self, provider: &Provider) -> ProviderType { + if let Some(key) = self.extract_key_raw(provider) { + // OAuth access_token 以 ya29. 开头 + if key.starts_with("ya29.") { + return ProviderType::GeminiCli; + } + // JSON 格式的 OAuth 凭证 + if key.starts_with('{') { + return ProviderType::GeminiCli; + } + } + ProviderType::Gemini + } + + /// 检测认证类型 + pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy { + match self.provider_type(provider) { + ProviderType::GeminiCli => AuthStrategy::GoogleOAuth, + _ => AuthStrategy::Google, + } + } + + /// 解析 OAuth 凭证 + pub fn parse_oauth_credentials(&self, key: &str) -> Option { + // 直接是 access_token + if key.starts_with("ya29.") { + return Some(OAuthCredentials { + access_token: key.to_string(), + refresh_token: None, + client_id: None, + client_secret: None, + }); + } + + // JSON 格式 + if key.starts_with('{') { + if let Ok(json) = serde_json::from_str::(key) { + let access_token = json + .get("access_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + let refresh_token = json + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let client_id = json + .get("client_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let client_secret = json + .get("client_secret") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 如果有 access_token 或 refresh_token,返回凭证 + if !access_token.is_empty() || refresh_token.is_some() { + return Some(OAuthCredentials { + access_token, + refresh_token, + client_id, + client_secret, + }); + } + } + } + + None + } + + /// 从 Provider 配置中提取原始 API Key + fn extract_key_raw(&self, provider: &Provider) -> Option { + if let Some(env) = provider.settings_config.get("env") { + // 优先使用 GOOGLE_GEMINI_API_KEY + if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + // 备选 GEMINI_API_KEY + if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + } + + // 尝试直接获取 + if let Some(key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + return Some(key.to_string()); + } + + None + } +} + +impl Default for GeminiAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ProviderAdapter for GeminiAdapter { + fn name(&self) -> &'static str { + "Gemini" + } + + fn extract_base_url(&self, provider: &Provider) -> Result { + // 从 env 中获取 + if let Some(env) = provider.settings_config.get("env") { + if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) { + return Ok(url.trim_end_matches('/').to_string()); + } + } + + // 尝试直接获取 + if let Some(url) = provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + if let Some(url) = provider + .settings_config + .get("baseURL") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + Err(ProxyError::ConfigError( + "Gemini Provider 缺少 base_url 配置".to_string(), + )) + } + + fn extract_auth(&self, provider: &Provider) -> Option { + let key = self.extract_key_raw(provider)?; + let strategy = self.detect_auth_type(provider); + + match strategy { + AuthStrategy::GoogleOAuth => { + // 解析 OAuth 凭证 + if let Some(creds) = self.parse_oauth_credentials(&key) { + Some(AuthInfo::with_access_token(key, creds.access_token)) + } else { + // 回退到普通 API Key + Some(AuthInfo::new(key, AuthStrategy::Google)) + } + } + _ => Some(AuthInfo::new(key, AuthStrategy::Google)), + } + } + + fn build_url(&self, base_url: &str, endpoint: &str) -> String { + let base_trimmed = base_url.trim_end_matches('/'); + let endpoint_trimmed = endpoint.trim_start_matches('/'); + + let mut url = format!("{base_trimmed}/{endpoint_trimmed}"); + + // 处理 /v1beta 路径去重 + let version_patterns = ["/v1beta", "/v1"]; + for pattern in &version_patterns { + let duplicate = format!("{pattern}{pattern}"); + if url.contains(&duplicate) { + url = url.replace(&duplicate, pattern); + } + } + + url + } + + fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { + match auth.strategy { + // OAuth Bearer 认证 + AuthStrategy::GoogleOAuth => { + let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); + request + .header("Authorization", format!("Bearer {token}")) + .header("x-goog-api-client", "GeminiCLI/1.0") + } + // API Key 认证 + _ => request.header("x-goog-api-key", &auth.api_key), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider(config: serde_json::Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Gemini".to_string(), + settings_config: config, + website_url: None, + category: Some("gemini".to_string()), + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + #[test] + fn test_extract_base_url_from_env() { + let adapter = GeminiAdapter::new(); + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta" + } + })); + + let url = adapter.extract_base_url(&provider).unwrap(); + assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta"); + } + + #[test] + fn test_extract_auth_api_key() { + let adapter = GeminiAdapter::new(); + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "AIza-test-key-12345678"); + assert_eq!(auth.strategy, AuthStrategy::Google); + assert!(auth.access_token.is_none()); + } + + #[test] + fn test_extract_auth_oauth_access_token() { + let adapter = GeminiAdapter::new(); + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + assert_eq!( + auth.access_token, + Some("ya29.test-access-token-12345".to_string()) + ); + } + + #[test] + fn test_extract_auth_oauth_json() { + let adapter = GeminiAdapter::new(); + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + assert_eq!(auth.access_token, Some("ya29.test-token".to_string())); + } + + #[test] + fn test_provider_type_detection() { + let adapter = GeminiAdapter::new(); + + // API Key + let api_key_provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "AIza-test-key" + } + })); + assert_eq!( + adapter.provider_type(&api_key_provider), + ProviderType::Gemini + ); + + // OAuth access_token + let oauth_provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "ya29.test-token" + } + })); + assert_eq!( + adapter.provider_type(&oauth_provider), + ProviderType::GeminiCli + ); + + // OAuth JSON + let oauth_json_provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}" + } + })); + assert_eq!( + adapter.provider_type(&oauth_json_provider), + ProviderType::GeminiCli + ); + } + + #[test] + fn test_extract_auth_fallback() { + let adapter = GeminiAdapter::new(); + let provider = create_provider(json!({ + "env": { + "GEMINI_API_KEY": "AIza-fallback-key" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "AIza-fallback-key"); + } + + #[test] + fn test_build_url_dedup() { + let adapter = GeminiAdapter::new(); + // 模拟 base_url 已包含 /v1beta,endpoint 也包含 /v1beta + let url = adapter.build_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-pro:generateContent", + ); + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent" + ); + } + + #[test] + fn test_build_url_normal() { + let adapter = GeminiAdapter::new(); + let url = adapter.build_url( + "https://generativelanguage.googleapis.com/v1beta", + "/models/gemini-pro:generateContent", + ); + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent" + ); + } + + #[test] + fn test_parse_oauth_credentials_direct_token() { + let adapter = GeminiAdapter::new(); + let creds = adapter + .parse_oauth_credentials("ya29.test-access-token") + .unwrap(); + assert_eq!(creds.access_token, "ya29.test-access-token"); + assert!(creds.refresh_token.is_none()); + } + + #[test] + fn test_parse_oauth_credentials_json() { + let adapter = GeminiAdapter::new(); + let creds = adapter + .parse_oauth_credentials( + "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}", + ) + .unwrap(); + assert_eq!(creds.access_token, "ya29.test"); + assert_eq!(creds.refresh_token, Some("1//refresh".to_string())); + } + + #[test] + fn test_parse_oauth_credentials_invalid() { + let adapter = GeminiAdapter::new(); + assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none()); + assert!(adapter.parse_oauth_credentials("invalid-json{").is_none()); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs new file mode 100644 index 000000000..f41a03b88 --- /dev/null +++ b/src-tauri/src/proxy/providers/mod.rs @@ -0,0 +1,424 @@ +//! Provider Adapters Module +//! +//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。 +//! +//! ## 模块结构 +//! - `adapter`: 定义 `ProviderAdapter` trait +//! - `auth`: 认证类型和策略 +//! - `claude`: Claude (Anthropic) 适配器 +//! - `codex`: Codex (OpenAI) 适配器 +//! - `gemini`: Gemini (Google) 适配器 +//! - `models`: API 数据模型 +//! - `transform`: 格式转换 + +mod adapter; +mod auth; +mod claude; +mod codex; +mod gemini; +pub mod models; +pub mod streaming; +pub mod transform; + +use crate::app_config::AppType; +use crate::provider::Provider; +use serde::{Deserialize, Serialize}; + +// 公开导出 +pub use adapter::ProviderAdapter; +pub use auth::{AuthInfo, AuthStrategy}; +pub use claude::ClaudeAdapter; +pub use codex::CodexAdapter; +pub use gemini::GeminiAdapter; + +/// 供应商类型枚举 +/// +/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。 +/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderType { + /// Anthropic 官方 API (x-api-key + anthropic-version) + Claude, + /// Claude 中转服务 (仅 Bearer 认证,无 x-api-key) + ClaudeAuth, + /// OpenAI Codex Response API + Codex, + /// Google Gemini API (x-goog-api-key) + Gemini, + /// Google Gemini CLI (OAuth Bearer) + GeminiCli, + /// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换) + OpenRouter, +} + +impl ProviderType { + /// 是否需要格式转换 + /// + /// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 + #[allow(dead_code)] + pub fn needs_transform(&self) -> bool { + matches!(self, ProviderType::OpenRouter) + } + + /// 获取默认端点 + #[allow(dead_code)] + pub fn default_endpoint(&self) -> &'static str { + match self { + ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com", + ProviderType::Codex => "https://api.openai.com", + ProviderType::Gemini | ProviderType::GeminiCli => { + "https://generativelanguage.googleapis.com" + } + ProviderType::OpenRouter => "https://openrouter.ai/api", + } + } + + /// 从 AppType 和 Provider 配置推断供应商类型 + /// + /// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型 + #[allow(dead_code)] + pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self { + match app_type { + AppType::Claude => { + // 检测是否为 OpenRouter + let adapter = ClaudeAdapter::new(); + if let Ok(base_url) = adapter.extract_base_url(provider) { + if base_url.contains("openrouter.ai") { + return ProviderType::OpenRouter; + } + } + // 检测是否为中转服务(仅 Bearer 认证) + // 注意:ProviderMeta 没有直接的 auth_mode 字段, + // 我们通过检查 settings_config 中的配置来判断 + // 检查 settings_config 中的 auth_mode + if let Some(auth_mode) = provider + .settings_config + .get("auth_mode") + .and_then(|v| v.as_str()) + { + if auth_mode == "bearer_only" { + return ProviderType::ClaudeAuth; + } + } + // 检查 env 中的 auth_mode + if let Some(env) = provider.settings_config.get("env") { + if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) { + if auth_mode == "bearer_only" { + return ProviderType::ClaudeAuth; + } + } + } + ProviderType::Claude + } + AppType::Codex => ProviderType::Codex, + AppType::Gemini => { + // 检测是否为 CLI 模式(OAuth) + let adapter = GeminiAdapter::new(); + if let Some(auth) = adapter.extract_auth(provider) { + let key = &auth.api_key; + // OAuth access_token 以 ya29. 开头 + if key.starts_with("ya29.") { + return ProviderType::GeminiCli; + } + // JSON 格式的 OAuth 凭证 + if key.starts_with('{') { + return ProviderType::GeminiCli; + } + } + ProviderType::Gemini + } + } + } + + /// 转换为字符串表示 + pub fn as_str(&self) -> &'static str { + match self { + ProviderType::Claude => "claude", + ProviderType::ClaudeAuth => "claude_auth", + ProviderType::Codex => "codex", + ProviderType::Gemini => "gemini", + ProviderType::GeminiCli => "gemini_cli", + ProviderType::OpenRouter => "openrouter", + } + } +} + +impl std::fmt::Display for ProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "claude" => Ok(ProviderType::Claude), + "claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth), + "codex" => Ok(ProviderType::Codex), + "gemini" => Ok(ProviderType::Gemini), + "gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli), + "openrouter" => Ok(ProviderType::OpenRouter), + _ => Err(format!("Invalid provider type: {s}")), + } + } +} + +/// 根据 AppType 获取对应的适配器 +pub fn get_adapter(app_type: &AppType) -> Box { + match app_type { + AppType::Claude => Box::new(ClaudeAdapter::new()), + AppType::Codex => Box::new(CodexAdapter::new()), + AppType::Gemini => Box::new(GeminiAdapter::new()), + } +} + +/// 根据 ProviderType 获取对应的适配器 +#[allow(dead_code)] +pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box { + match provider_type { + ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => { + Box::new(ClaudeAdapter::new()) + } + ProviderType::Codex => Box::new(CodexAdapter::new()), + ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider(config: serde_json::Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Provider".to_string(), + settings_config: config, + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + #[test] + fn test_provider_type_needs_transform() { + assert!(!ProviderType::Claude.needs_transform()); + assert!(!ProviderType::ClaudeAuth.needs_transform()); + assert!(!ProviderType::Codex.needs_transform()); + assert!(!ProviderType::Gemini.needs_transform()); + assert!(!ProviderType::GeminiCli.needs_transform()); + assert!(ProviderType::OpenRouter.needs_transform()); + } + + #[test] + fn test_provider_type_default_endpoint() { + assert_eq!( + ProviderType::Claude.default_endpoint(), + "https://api.anthropic.com" + ); + assert_eq!( + ProviderType::ClaudeAuth.default_endpoint(), + "https://api.anthropic.com" + ); + assert_eq!( + ProviderType::Codex.default_endpoint(), + "https://api.openai.com" + ); + assert_eq!( + ProviderType::Gemini.default_endpoint(), + "https://generativelanguage.googleapis.com" + ); + assert_eq!( + ProviderType::GeminiCli.default_endpoint(), + "https://generativelanguage.googleapis.com" + ); + assert_eq!( + ProviderType::OpenRouter.default_endpoint(), + "https://openrouter.ai/api" + ); + } + + #[test] + fn test_provider_type_from_str() { + assert_eq!( + "claude".parse::().unwrap(), + ProviderType::Claude + ); + assert_eq!( + "claude_auth".parse::().unwrap(), + ProviderType::ClaudeAuth + ); + assert_eq!( + "claude-auth".parse::().unwrap(), + ProviderType::ClaudeAuth + ); + assert_eq!( + "codex".parse::().unwrap(), + ProviderType::Codex + ); + assert_eq!( + "gemini".parse::().unwrap(), + ProviderType::Gemini + ); + assert_eq!( + "gemini_cli".parse::().unwrap(), + ProviderType::GeminiCli + ); + assert_eq!( + "gemini-cli".parse::().unwrap(), + ProviderType::GeminiCli + ); + assert_eq!( + "openrouter".parse::().unwrap(), + ProviderType::OpenRouter + ); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_provider_type_as_str() { + assert_eq!(ProviderType::Claude.as_str(), "claude"); + assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth"); + assert_eq!(ProviderType::Codex.as_str(), "codex"); + assert_eq!(ProviderType::Gemini.as_str(), "gemini"); + assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli"); + assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter"); + } + + #[test] + fn test_provider_type_serde() { + // Test serialization + let claude = ProviderType::Claude; + let serialized = serde_json::to_string(&claude).unwrap(); + assert_eq!(serialized, "\"claude\""); + + let claude_auth = ProviderType::ClaudeAuth; + let serialized = serde_json::to_string(&claude_auth).unwrap(); + assert_eq!(serialized, "\"claude_auth\""); + + // Test deserialization + let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap(); + assert_eq!(deserialized, ProviderType::Claude); + + let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap(); + assert_eq!(deserialized, ProviderType::GeminiCli); + } + + #[test] + fn test_from_app_type_claude_direct() { + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "sk-ant-test" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider); + assert_eq!(provider_type, ProviderType::Claude); + } + + #[test] + fn test_from_app_type_claude_openrouter() { + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "OPENROUTER_API_KEY": "sk-or-test" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider); + assert_eq!(provider_type, ProviderType::OpenRouter); + } + + #[test] + fn test_from_app_type_claude_auth() { + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://some-proxy.com", + "ANTHROPIC_AUTH_TOKEN": "sk-test" + }, + "auth_mode": "bearer_only" + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider); + assert_eq!(provider_type, ProviderType::ClaudeAuth); + } + + #[test] + fn test_from_app_type_codex() { + let provider = create_provider(json!({ + "env": { + "OPENAI_API_KEY": "sk-test" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider); + assert_eq!(provider_type, ProviderType::Codex); + } + + #[test] + fn test_from_app_type_gemini_api_key() { + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "AIza-test-key" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider); + assert_eq!(provider_type, ProviderType::Gemini); + } + + #[test] + fn test_from_app_type_gemini_cli_oauth() { + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider); + assert_eq!(provider_type, ProviderType::GeminiCli); + } + + #[test] + fn test_from_app_type_gemini_cli_json() { + let provider = create_provider(json!({ + "env": { + "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}" + } + })); + + let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider); + assert_eq!(provider_type, ProviderType::GeminiCli); + } + + #[test] + fn test_get_adapter_for_provider_type() { + let adapter = get_adapter_for_provider_type(&ProviderType::Claude); + assert_eq!(adapter.name(), "Claude"); + + let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth); + assert_eq!(adapter.name(), "Claude"); + + let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter); + assert_eq!(adapter.name(), "Claude"); + + let adapter = get_adapter_for_provider_type(&ProviderType::Codex); + assert_eq!(adapter.name(), "Codex"); + + let adapter = get_adapter_for_provider_type(&ProviderType::Gemini); + assert_eq!(adapter.name(), "Gemini"); + + let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli); + assert_eq!(adapter.name(), "Gemini"); + } +} diff --git a/src-tauri/src/proxy/providers/models/anthropic.rs b/src-tauri/src/proxy/providers/models/anthropic.rs new file mode 100644 index 000000000..02791ecce --- /dev/null +++ b/src-tauri/src/proxy/providers/models/anthropic.rs @@ -0,0 +1,104 @@ +//! Anthropic API 数据模型 +//! +//! 用于 Anthropic Messages API 的请求/响应格式转换 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Anthropic 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnthropicRequest { + pub model: String, + pub messages: Vec, + pub max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, // 可以是 String 或 Vec + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// Anthropic 消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnthropicMessage { + pub role: String, + pub content: Value, // String 或 Vec +} + +/// Anthropic 内容块 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AnthropicContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { source: ImageSource }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: Value, + }, + #[serde(rename = "tool_result")] + ToolResult { tool_use_id: String, content: Value }, +} + +/// 图片来源 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageSource { + #[serde(rename = "type")] + pub source_type: String, + pub media_type: String, + pub data: String, +} + +/// Anthropic 工具定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnthropicTool { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub input_schema: Value, +} + +/// Anthropic 响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnthropicResponse { + pub id: String, + #[serde(rename = "type")] + pub response_type: String, + pub role: String, + pub content: Vec, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequence: Option, + pub usage: AnthropicUsage, +} + +/// Anthropic 响应内容 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AnthropicResponseContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: Value, + }, +} + +/// Anthropic 使用量 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnthropicUsage { + pub input_tokens: u32, + pub output_tokens: u32, +} diff --git a/src-tauri/src/proxy/providers/models/mod.rs b/src-tauri/src/proxy/providers/models/mod.rs new file mode 100644 index 000000000..df3d9e109 --- /dev/null +++ b/src-tauri/src/proxy/providers/models/mod.rs @@ -0,0 +1,6 @@ +//! API 数据模型 +//! +//! 定义 Anthropic 和 OpenAI API 的请求/响应结构 + +pub mod anthropic; +pub mod openai; diff --git a/src-tauri/src/proxy/providers/models/openai.rs b/src-tauri/src/proxy/providers/models/openai.rs new file mode 100644 index 000000000..e04aefb4d --- /dev/null +++ b/src-tauri/src/proxy/providers/models/openai.rs @@ -0,0 +1,113 @@ +//! OpenAI API 数据模型 +//! +//! 用于 OpenAI Chat Completions API 的请求/响应格式转换 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// OpenAI 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// OpenAI 消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIMessage { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, // String 或 Vec + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +/// OpenAI 内容部分 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum OpenAIContentPart { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image_url")] + ImageUrl { image_url: ImageUrl }, +} + +/// 图片 URL +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageUrl { + pub url: String, +} + +/// OpenAI 工具调用 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: OpenAIFunction, +} + +/// OpenAI 函数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIFunction { + pub name: String, + pub arguments: String, // JSON 字符串 +} + +/// OpenAI 工具定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAITool { + #[serde(rename = "type")] + pub tool_type: String, + pub function: OpenAIFunctionDef, +} + +/// OpenAI 函数定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIFunctionDef { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub parameters: Value, +} + +/// OpenAI 响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +/// OpenAI 选择 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIChoice { + pub index: u32, + pub message: OpenAIMessage, + #[serde(skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, +} + +/// OpenAI 使用量 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs new file mode 100644 index 000000000..ab310a4b5 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -0,0 +1,338 @@ +//! 流式响应转换模块 +//! +//! 实现 OpenAI SSE → Anthropic SSE 格式转换 + +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// OpenAI 流式响应数据结构 +#[derive(Debug, Deserialize)] +struct OpenAIStreamChunk { + id: String, + model: String, + choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct StreamChoice { + delta: Delta, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct Delta { + #[serde(default)] + content: Option, + #[serde(default)] + reasoning: Option, // OpenRouter 的推理内容 + #[serde(default)] + tool_calls: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +struct DeltaToolCall { + index: usize, + #[serde(default)] + id: Option, + #[serde(rename = "type", default)] + call_type: Option, + #[serde(default)] + function: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct DeltaFunction { + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, +} + +/// OpenAI 流式响应的 usage 信息(完整版) +#[derive(Debug, Deserialize)] +struct Usage { + #[serde(default)] + prompt_tokens: u32, + #[serde(default)] + completion_tokens: u32, +} + +/// 创建 Anthropic SSE 流 +pub fn create_anthropic_sse_stream( + stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut message_id = None; + let mut current_model = None; + let mut content_index = 0; + let mut has_sent_message_start = false; + let mut current_block_type: Option = None; + let mut tool_call_id = None; + + log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======"); + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + while let Some(pos) = buffer.find("\n\n") { + let line = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + if line.trim().is_empty() { + continue; + } + + for l in line.lines() { + if let Some(data) = l.strip_prefix("data: ") { + if data.trim() == "[DONE]" { + log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); + let event = json!({"type": "message_stop"}); + let sse_data = format!("event: message_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); + yield Ok(Bytes::from(sse_data)); + continue; + } + + if let Ok(chunk) = serde_json::from_str::(data) { + // 记录原始 OpenAI 事件(格式化显示) + if let Ok(json_value) = serde_json::from_str::(data) { + log::info!( + "[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}", + serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) + ); + } else { + log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}"); + } + + if message_id.is_none() { + message_id = Some(chunk.id.clone()); + } + if current_model.is_none() { + current_model = Some(chunk.model.clone()); + } + + if let Some(choice) = chunk.choices.first() { + if !has_sent_message_start { + let event = json!({ + "type": "message_start", + "message": { + "id": message_id.clone().unwrap_or_default(), + "type": "message", + "role": "assistant", + "model": current_model.clone().unwrap_or_default(), + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + } + }); + let sse_data = format!("event: message_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + has_sent_message_start = true; + } + + // 处理 reasoning(thinking) + if let Some(reasoning) = &choice.delta.reasoning { + if current_block_type.is_none() { + let event = json!({ + "type": "content_block_start", + "index": content_index, + "content_block": { + "type": "thinking", + "thinking": "" + } + }); + let sse_data = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + current_block_type = Some("thinking".to_string()); + } + + let event = json!({ + "type": "content_block_delta", + "index": content_index, + "delta": { + "type": "thinking_delta", + "thinking": reasoning + } + }); + let sse_data = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + } + + // 处理文本内容 + if let Some(content) = &choice.delta.content { + if !content.is_empty() { + if current_block_type.as_deref() != Some("text") { + if current_block_type.is_some() { + let event = json!({ + "type": "content_block_stop", + "index": content_index + }); + let sse_data = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + content_index += 1; + } + + let event = json!({ + "type": "content_block_start", + "index": content_index, + "content_block": { + "type": "text", + "text": "" + } + }); + let sse_data = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + current_block_type = Some("text".to_string()); + } + + let event = json!({ + "type": "content_block_delta", + "index": content_index, + "delta": { + "type": "text_delta", + "text": content + } + }); + let sse_data = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + } + } + + // 处理工具调用 + if let Some(tool_calls) = &choice.delta.tool_calls { + for tool_call in tool_calls { + if let Some(id) = &tool_call.id { + if current_block_type.is_some() { + let event = json!({ + "type": "content_block_stop", + "index": content_index + }); + let sse_data = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + content_index += 1; + } + + tool_call_id = Some(id.clone()); + } + + if let Some(function) = &tool_call.function { + if let Some(name) = &function.name { + let event = json!({ + "type": "content_block_start", + "index": content_index, + "content_block": { + "type": "tool_use", + "id": tool_call_id.clone().unwrap_or_default(), + "name": name + } + }); + let sse_data = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + current_block_type = Some("tool_use".to_string()); + } + + if let Some(args) = &function.arguments { + let event = json!({ + "type": "content_block_delta", + "index": content_index, + "delta": { + "type": "input_json_delta", + "partial_json": args + } + }); + let sse_data = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + } + } + } + } + + // 处理 finish_reason + if let Some(finish_reason) = &choice.finish_reason { + if current_block_type.is_some() { + let event = json!({ + "type": "content_block_stop", + "index": content_index + }); + let sse_data = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + } + + let stop_reason = map_stop_reason(Some(finish_reason)); + // 构建 usage 信息,包含 input_tokens 和 output_tokens + let usage_json = chunk.usage.as_ref().map(|u| json!({ + "input_tokens": u.prompt_tokens, + "output_tokens": u.completion_tokens + })); + let event = json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": null + }, + "usage": usage_json + }); + let sse_data = format!("event: message_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + } + } + } + } + } + } + } + Err(e) => { + log::error!("Stream error: {e}"); + let error_event = json!({ + "type": "error", + "error": { + "type": "stream_error", + "message": format!("Stream error: {e}") + } + }); + let sse_data = format!("event: error\ndata: {}\n\n", + serde_json::to_string(&error_event).unwrap_or_default()); + yield Ok(Bytes::from(sse_data)); + break; + } + } + } + } +} + +/// 映射停止原因 +fn map_stop_reason(finish_reason: Option<&str>) -> Option { + finish_reason.map(|r| { + match r { + "tool_calls" => "tool_use", + "stop" => "end_turn", + "length" => "max_tokens", + _ => "end_turn", + } + .to_string() + }) +} diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs new file mode 100644 index 000000000..a841750ed --- /dev/null +++ b/src-tauri/src/proxy/providers/transform.rs @@ -0,0 +1,640 @@ +//! 格式转换模块 +//! +//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持 +//! 参考: anthropic-proxy-rs + +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use serde_json::{json, Value}; + +/// 从 Provider 配置中获取模型映射 +fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String { + let env = provider.settings_config.get("env"); + let model_lower = model.to_lowercase(); + + // 检测 thinking 参数 + let has_thinking = body + .get("thinking") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("type")) + .and_then(|t| t.as_str()) + == Some("enabled"); + + if let Some(env) = env { + // 如果启用 thinking,优先使用推理模型 + if has_thinking { + if let Some(m) = env + .get("ANTHROPIC_REASONING_MODEL") + .and_then(|v| v.as_str()) + { + log::debug!("[Transform] 使用推理模型: {m}"); + return m.to_string(); + } + } + + // 根据模型类型选择配置模型 + if model_lower.contains("haiku") { + if let Some(m) = env + .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") + .and_then(|v| v.as_str()) + { + return m.to_string(); + } + } + if model_lower.contains("opus") { + if let Some(m) = env + .get("ANTHROPIC_DEFAULT_OPUS_MODEL") + .and_then(|v| v.as_str()) + { + return m.to_string(); + } + } + if model_lower.contains("sonnet") { + if let Some(m) = env + .get("ANTHROPIC_DEFAULT_SONNET_MODEL") + .and_then(|v| v.as_str()) + { + return m.to_string(); + } + } + // 默认使用 ANTHROPIC_MODEL + if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) { + return m.to_string(); + } + } + + model.to_string() +} + +/// Anthropic 请求 → OpenAI 请求 +pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { + let mut result = json!({}); + + // 模型映射:使用 Provider 配置中的模型(支持 thinking 参数) + if let Some(model) = body.get("model").and_then(|m| m.as_str()) { + let mapped_model = get_model_from_provider(model, provider, &body); + result["model"] = json!(mapped_model); + } + + let mut messages = Vec::new(); + + // 处理 system prompt + if let Some(system) = body.get("system") { + if let Some(text) = system.as_str() { + // 单个字符串 + messages.push(json!({"role": "system", "content": text})); + } else if let Some(arr) = system.as_array() { + // 多个 system message + for msg in arr { + if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { + messages.push(json!({"role": "system", "content": text})); + } + } + } + } + + // 转换 messages + if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) { + for msg in msgs { + let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user"); + let content = msg.get("content"); + let converted = convert_message_to_openai(role, content)?; + messages.extend(converted); + } + } + + result["messages"] = json!(messages); + + // 转换参数 + if let Some(v) = body.get("max_tokens") { + result["max_tokens"] = v.clone(); + } + if let Some(v) = body.get("temperature") { + result["temperature"] = v.clone(); + } + if let Some(v) = body.get("top_p") { + result["top_p"] = v.clone(); + } + if let Some(v) = body.get("stop_sequences") { + result["stop"] = v.clone(); + } + if let Some(v) = body.get("stream") { + result["stream"] = v.clone(); + } + + // 转换 tools (过滤 BatchTool) + if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) { + let openai_tools: Vec = tools + .iter() + .filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool")) + .map(|t| { + json!({ + "type": "function", + "function": { + "name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""), + "description": t.get("description"), + "parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({}))) + } + }) + }) + .collect(); + + if !openai_tools.is_empty() { + result["tools"] = json!(openai_tools); + } + } + + if let Some(v) = body.get("tool_choice") { + result["tool_choice"] = v.clone(); + } + + Ok(result) +} + +/// 转换单条消息到 OpenAI 格式(可能产生多条消息) +fn convert_message_to_openai( + role: &str, + content: Option<&Value>, +) -> Result, ProxyError> { + let mut result = Vec::new(); + + let content = match content { + Some(c) => c, + None => { + result.push(json!({"role": role, "content": null})); + return Ok(result); + } + }; + + // 字符串内容 + if let Some(text) = content.as_str() { + result.push(json!({"role": role, "content": text})); + return Ok(result); + } + + // 数组内容(多模态/工具调用) + if let Some(blocks) = content.as_array() { + let mut content_parts = Vec::new(); + let mut tool_calls = Vec::new(); + + for block in blocks { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + content_parts.push(json!({"type": "text", "text": text})); + } + } + "image" => { + if let Some(source) = block.get("source") { + let media_type = source + .get("media_type") + .and_then(|m| m.as_str()) + .unwrap_or("image/png"); + let data = source.get("data").and_then(|d| d.as_str()).unwrap_or(""); + content_parts.push(json!({ + "type": "image_url", + "image_url": {"url": format!("data:{};base64,{}", media_type, data)} + })); + } + } + "tool_use" => { + let id = block.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or(json!({})); + tool_calls.push(json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": serde_json::to_string(&input).unwrap_or_default() + } + })); + } + "tool_result" => { + // tool_result 变成单独的 tool role 消息 + let tool_use_id = block + .get("tool_use_id") + .and_then(|i| i.as_str()) + .unwrap_or(""); + let content_val = block.get("content"); + let content_str = match content_val { + Some(Value::String(s)) => s.clone(), + Some(v) => serde_json::to_string(v).unwrap_or_default(), + None => String::new(), + }; + result.push(json!({ + "role": "tool", + "tool_call_id": tool_use_id, + "content": content_str + })); + } + "thinking" => { + // 跳过 thinking blocks + } + _ => {} + } + } + + // 添加带内容和/或工具调用的消息 + if !content_parts.is_empty() || !tool_calls.is_empty() { + let mut msg = json!({"role": role}); + + // 内容处理 + if content_parts.is_empty() { + msg["content"] = Value::Null; + } else if content_parts.len() == 1 { + if let Some(text) = content_parts[0].get("text") { + msg["content"] = text.clone(); + } else { + msg["content"] = json!(content_parts); + } + } else { + msg["content"] = json!(content_parts); + } + + // 工具调用 + if !tool_calls.is_empty() { + msg["tool_calls"] = json!(tool_calls); + } + + result.push(msg); + } + + return Ok(result); + } + + // 其他情况直接透传 + result.push(json!({"role": role, "content": content})); + Ok(result) +} + +/// 清理 JSON schema(移除不支持的 format) +fn clean_schema(mut schema: Value) -> Value { + if let Some(obj) = schema.as_object_mut() { + // 移除 "format": "uri" + if obj.get("format").and_then(|v| v.as_str()) == Some("uri") { + obj.remove("format"); + } + + // 递归清理嵌套 schema + if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) { + for (_, value) in properties.iter_mut() { + *value = clean_schema(value.clone()); + } + } + + if let Some(items) = obj.get_mut("items") { + *items = clean_schema(items.clone()); + } + } + schema +} + +/// OpenAI 响应 → Anthropic 响应 +pub fn openai_to_anthropic(body: Value) -> Result { + let choices = body + .get("choices") + .and_then(|c| c.as_array()) + .ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?; + + let choice = choices + .first() + .ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?; + + let message = choice + .get("message") + .ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?; + + let mut content = Vec::new(); + + // 文本内容 + if let Some(text) = message.get("content").and_then(|c| c.as_str()) { + if !text.is_empty() { + content.push(json!({"type": "text", "text": text})); + } + } + + // 工具调用 + if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) { + for tc in tool_calls { + let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let empty_obj = json!({}); + let func = tc.get("function").unwrap_or(&empty_obj); + let name = func.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let args_str = func + .get("arguments") + .and_then(|a| a.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + + content.push(json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input + })); + } + } + + // 映射 finish_reason → stop_reason + let stop_reason = choice + .get("finish_reason") + .and_then(|r| r.as_str()) + .map(|r| match r { + "stop" => "end_turn", + "length" => "max_tokens", + "tool_calls" => "tool_use", + other => other, + }); + + // usage + let usage = body.get("usage").cloned().unwrap_or(json!({})); + let input_tokens = usage + .get("prompt_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let output_tokens = usage + .get("completion_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let result = json!({ + "id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""), + "type": "message", + "role": "assistant", + "content": content, + "model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""), + "stop_reason": stop_reason, + "stop_sequence": null, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens + } + }); + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_provider(env_config: Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Provider".to_string(), + settings_config: json!({"env": env_config}), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + fn create_openrouter_provider() -> Provider { + create_provider(json!({ + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5" + })) + } + + #[test] + fn test_anthropic_to_openai_simple() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + // opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL + assert_eq!(result["model"], "anthropic/claude-opus-4.5"); + assert_eq!(result["max_tokens"], 1024); + assert_eq!(result["messages"][0]["role"], "user"); + assert_eq!(result["messages"][0]["content"], "Hello"); + } + + #[test] + fn test_anthropic_to_openai_with_system() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!( + result["messages"][0]["content"], + "You are a helpful assistant." + ); + assert_eq!(result["messages"][1]["role"], "user"); + } + + #[test] + fn test_anthropic_to_openai_with_tools() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "What's the weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + assert_eq!(result["tools"][0]["type"], "function"); + assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); + } + + #[test] + fn test_anthropic_to_openai_tool_use() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check"}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + let msg = &result["messages"][0]; + assert_eq!(msg["role"], "assistant"); + assert!(msg.get("tool_calls").is_some()); + assert_eq!(msg["tool_calls"][0]["id"], "call_123"); + } + + #[test] + fn test_anthropic_to_openai_tool_result() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"} + ] + }] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + let msg = &result["messages"][0]; + assert_eq!(msg["role"], "tool"); + assert_eq!(msg["tool_call_id"], "call_123"); + assert_eq!(msg["content"], "Sunny, 25°C"); + } + + #[test] + fn test_openai_to_anthropic_simple() { + let input = json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + + let result = openai_to_anthropic(input).unwrap(); + assert_eq!(result["id"], "chatcmpl-123"); + assert_eq!(result["type"], "message"); + assert_eq!(result["content"][0]["type"], "text"); + assert_eq!(result["content"][0]["text"], "Hello!"); + assert_eq!(result["stop_reason"], "end_turn"); + assert_eq!(result["usage"]["input_tokens"], 10); + assert_eq!(result["usage"]["output_tokens"], 5); + } + + #[test] + fn test_openai_to_anthropic_with_tool_calls() { + let input = json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"} + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + + let result = openai_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "tool_use"); + assert_eq!(result["content"][0]["id"], "call_123"); + assert_eq!(result["content"][0]["name"], "get_weather"); + assert_eq!(result["content"][0]["input"]["location"], "Tokyo"); + assert_eq!(result["stop_reason"], "tool_use"); + } + + #[test] + fn test_model_mapping_from_provider() { + let provider = create_openrouter_provider(); + let body = json!({"model": "test"}); + + // sonnet 模型 + assert_eq!( + get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body), + "anthropic/claude-sonnet-4.5" + ); + + // haiku 模型 + assert_eq!( + get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body), + "anthropic/claude-haiku-4.5" + ); + + // opus 模型 + assert_eq!( + get_model_from_provider("claude-opus-4-5", &provider, &body), + "anthropic/claude-opus-4.5" + ); + } + + #[test] + fn test_anthropic_to_openai_model_mapping() { + let provider = create_openrouter_provider(); + let input = json!({ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); + } + + #[test] + fn test_thinking_parameter_detection() { + let mut provider = create_openrouter_provider(); + // 添加推理模型配置 + if let Some(env) = provider.settings_config.get_mut("env") { + env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); + } + + let input = json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "thinking": {"type": "enabled"}, + "messages": [{"role": "user", "content": "Solve this problem"}] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + // 应该使用推理模型 + assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended"); + } + + #[test] + fn test_thinking_parameter_disabled() { + let mut provider = create_openrouter_provider(); + if let Some(env) = provider.settings_config.get_mut("env") { + env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); + } + + let input = json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "thinking": {"type": "disabled"}, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_openai(input, &provider).unwrap(); + // 应该使用普通模型 + assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); + } +} diff --git a/src-tauri/src/proxy/response_handler.rs b/src-tauri/src/proxy/response_handler.rs new file mode 100644 index 000000000..7045643a0 --- /dev/null +++ b/src-tauri/src/proxy/response_handler.rs @@ -0,0 +1,214 @@ +//! Response Handler - 统一响应处理 +//! +//! 提供流式和非流式响应的统一处理接口 + +use super::session::ProxySession; +use super::usage::parser::TokenUsage; +use super::ProxyError; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tokio::time::timeout; + +/// 响应类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ResponseType { + /// 流式响应 (SSE) + Stream, + /// 非流式响应 + NonStream, +} + +impl ResponseType { + /// 从 Content-Type 检测响应类型 + #[allow(dead_code)] + pub fn from_content_type(content_type: &str) -> Self { + if content_type.contains("text/event-stream") { + ResponseType::Stream + } else { + ResponseType::NonStream + } + } +} + +/// 流式响应处理器 +#[allow(dead_code)] +pub struct StreamHandler { + /// 空闲超时时间 + idle_timeout: Duration, + /// 收集的事件 + events: Arc>>, +} + +#[allow(dead_code)] +impl StreamHandler { + /// 创建新的流式处理器 + pub fn new(idle_timeout_secs: u64) -> Self { + Self { + idle_timeout: Duration::from_secs(idle_timeout_secs), + events: Arc::new(Mutex::new(Vec::new())), + } + } + + /// 处理流式响应,返回分流后的客户端流 + /// + /// 客户端流立即返回,内部流在后台收集事件 + pub fn handle_stream( + &self, + stream: S, + ) -> impl Stream> + Send + where + S: Stream> + Send + 'static, + { + let events = self.events.clone(); + let idle_timeout = self.idle_timeout; + + async_stream::stream! { + let mut _last_activity = Instant::now(); + let mut buffer = String::new(); + + tokio::pin!(stream); + + loop { + let chunk_result = timeout(idle_timeout, stream.next()).await; + + match chunk_result { + Ok(Some(Ok(bytes))) => { + _last_activity = Instant::now(); + + // 解析 SSE 事件 + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // 提取完整事件 + while let Some(pos) = buffer.find("\n\n") { + let event_text = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + for line in event_text.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data.trim() != "[DONE]" { + if let Ok(json) = serde_json::from_str::(data) { + let mut guard = events.lock().await; + guard.push(json); + } + } + } + } + } + + yield Ok(bytes); + } + Ok(Some(Err(e))) => { + log::error!("流错误: {e}"); + yield Err(std::io::Error::other(e.to_string())); + break; + } + Ok(None) => { + // 流结束 + break; + } + Err(_) => { + // 空闲超时 + log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据"); + yield Err(std::io::Error::other("Stream idle timeout")); + break; + } + } + } + } + } + + /// 获取收集的事件 + pub async fn get_events(&self) -> Vec { + let guard = self.events.lock().await; + guard.clone() + } + + /// 从收集的事件中提取 Token 使用量 + pub async fn extract_usage(&self, session: &ProxySession) -> Option { + let events = self.get_events().await; + + match session.client_format { + super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events), + super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events), + super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => { + TokenUsage::from_gemini_stream_chunks(&events) + } + _ => None, + } + } +} + +/// 非流式响应处理器 +#[allow(dead_code)] +pub struct NonStreamHandler; + +#[allow(dead_code)] +impl NonStreamHandler { + /// 处理非流式响应 + /// + /// 克隆响应体用于后台解析,原始响应立即返回 + pub async fn handle_response( + body: &[u8], + session: &ProxySession, + ) -> Result, ProxyError> { + let json: Value = serde_json::from_slice(body) + .map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?; + + let usage = match session.client_format { + super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json), + super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json), + super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => { + TokenUsage::from_gemini_response(&json) + } + super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json), + _ => None, + }; + + Ok(usage) + } +} + +/// 统一响应分发器 +#[allow(dead_code)] +pub struct ResponseDispatcher; + +#[allow(dead_code)] +impl ResponseDispatcher { + /// 判断响应类型 + pub fn detect_type(content_type: &str) -> ResponseType { + ResponseType::from_content_type(content_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_response_type_detection() { + assert_eq!( + ResponseType::from_content_type("text/event-stream"), + ResponseType::Stream + ); + assert_eq!( + ResponseType::from_content_type("text/event-stream; charset=utf-8"), + ResponseType::Stream + ); + assert_eq!( + ResponseType::from_content_type("application/json"), + ResponseType::NonStream + ); + } + + #[test] + fn test_stream_handler_creation() { + let handler = StreamHandler::new(30); + assert_eq!(handler.idle_timeout, Duration::from_secs(30)); + } +} diff --git a/src-tauri/src/proxy/router.rs b/src-tauri/src/proxy/router.rs new file mode 100644 index 000000000..47efa952b --- /dev/null +++ b/src-tauri/src/proxy/router.rs @@ -0,0 +1,69 @@ +//! Provider路由器 +//! +//! 负责选择合适的Provider进行请求转发 + +use super::ProxyError; +use crate::{app_config::AppType, database::Database, provider::Provider}; +use std::sync::Arc; + +pub struct ProviderRouter { + db: Arc, +} + +impl ProviderRouter { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// 选择Provider(只使用标记为代理目标的 Provider) + pub async fn select_provider( + &self, + app_type: &AppType, + _failed_ids: &[String], + ) -> Result { + // 1. 获取 Proxy Target Provider ID + let proxy_target_id = self + .db + .get_proxy_target_provider(app_type.as_str()) + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + + let target_id = proxy_target_id.ok_or_else(|| { + log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str()); + ProxyError::NoAvailableProvider + })?; + + // 2. 获取所有 Provider + let providers = self + .db + .get_all_providers(app_type.as_str()) + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + + // 3. 找到目标 Provider + let target = providers.get(&target_id).ok_or_else(|| { + log::warn!( + "[{}] 代理目标 Provider 不存在: {}", + app_type.as_str(), + target_id + ); + ProxyError::NoAvailableProvider + })?; + + log::info!( + "[{}] 使用代理目标 Provider: {}", + app_type.as_str(), + target.name + ); + Ok(target.clone()) + } + + /// 更新Provider健康状态(保留接口但不影响选择) + pub async fn update_health( + &self, + _provider: &Provider, + _app_type: &AppType, + _success: bool, + _error_msg: Option, + ) { + // 不再记录健康状态 + } +} diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs new file mode 100644 index 000000000..10932c380 --- /dev/null +++ b/src-tauri/src/proxy/server.rs @@ -0,0 +1,168 @@ +//! HTTP代理服务器 +//! +//! 基于Axum的HTTP服务器,处理代理请求 + +use super::{handlers, types::*, ProxyError}; +use crate::database::Database; +use axum::{ + routing::{get, post}, + Router, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::{oneshot, RwLock}; +use tower_http::cors::{Any, CorsLayer}; + +/// 代理服务器状态(共享) +#[derive(Clone)] +pub struct ProxyState { + pub db: Arc, + pub config: Arc>, + pub status: Arc>, + pub start_time: Arc>>, +} + +/// 代理HTTP服务器 +pub struct ProxyServer { + config: ProxyConfig, + state: ProxyState, + shutdown_tx: Arc>>>, +} + +impl ProxyServer { + pub fn new(config: ProxyConfig, db: Arc) -> Self { + let state = ProxyState { + db, + config: Arc::new(RwLock::new(config.clone())), + status: Arc::new(RwLock::new(ProxyStatus::default())), + start_time: Arc::new(RwLock::new(None)), + }; + + Self { + config, + state, + shutdown_tx: Arc::new(RwLock::new(None)), + } + } + + pub async fn start(&self) -> Result { + // 检查是否已在运行 + if self.shutdown_tx.read().await.is_some() { + return Err(ProxyError::AlreadyRunning); + } + + let addr: SocketAddr = + format!("{}:{}", self.config.listen_address, self.config.listen_port) + .parse() + .map_err(|e| ProxyError::BindFailed(format!("无效的地址: {e}")))?; + + // 创建关闭通道 + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + // 构建路由 + let app = self.build_router(); + + // 绑定监听器 + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| ProxyError::BindFailed(e.to_string()))?; + + log::info!("代理服务器启动于 {addr}"); + + // 保存关闭句柄 + *self.shutdown_tx.write().await = Some(shutdown_tx); + + // 更新状态 + let mut status = self.state.status.write().await; + status.running = true; + status.address = self.config.listen_address.clone(); + status.port = self.config.listen_port; + drop(status); + + // 记录启动时间 + *self.state.start_time.write().await = Some(std::time::Instant::now()); + + // 启动服务器 + let state = self.state.clone(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + shutdown_rx.await.ok(); + }) + .await + .ok(); + + // 服务器停止后更新状态 + state.status.write().await.running = false; + *state.start_time.write().await = None; + }); + + Ok(ProxyServerInfo { + address: self.config.listen_address.clone(), + port: self.config.listen_port, + started_at: chrono::Utc::now().to_rfc3339(), + }) + } + + pub async fn stop(&self) -> Result<(), ProxyError> { + if let Some(tx) = self.shutdown_tx.write().await.take() { + let _ = tx.send(()); + Ok(()) + } else { + Err(ProxyError::NotRunning) + } + } + + pub async fn get_status(&self) -> ProxyStatus { + let mut status = self.state.status.read().await.clone(); + + // 计算运行时间 + if let Some(start) = *self.state.start_time.read().await { + status.uptime_seconds = start.elapsed().as_secs(); + } + + // 获取所有活跃的代理目标 + if let Ok(targets) = self.state.db.get_all_proxy_targets() { + status.active_targets = targets + .into_iter() + .map(|(app_type, name, id)| ActiveTarget { + app_type, + provider_name: name, + provider_id: id, + }) + .collect(); + } + + status + } + + fn build_router(&self) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + Router::new() + // 健康检查 + .route("/health", get(handlers::health_check)) + .route("/status", get(handlers::get_status)) + // Claude API + .route("/v1/messages", post(handlers::handle_messages)) + // OpenAI Chat Completions API (Codex CLI) + .route( + "/v1/chat/completions", + post(handlers::handle_chat_completions), + ) + // OpenAI Responses API (Codex CLI) + .route("/v1/responses", post(handlers::handle_responses)) + // Gemini API + .route("/v1beta/*path", post(handlers::handle_gemini)) + .layer(cors) + .with_state(self.state.clone()) + } + + /// 在不重启服务的情况下更新运行时配置 + pub async fn apply_runtime_config(&self, config: &ProxyConfig) { + *self.state.config.write().await = config.clone(); + } +} diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs new file mode 100644 index 000000000..3e1d14e56 --- /dev/null +++ b/src-tauri/src/proxy/session.rs @@ -0,0 +1,298 @@ +//! Proxy Session - 请求会话管理 +//! +//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。 + +use std::time::Instant; +use uuid::Uuid; + +/// 客户端请求格式 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ClientFormat { + /// Claude Messages API (/v1/messages) + Claude, + /// Codex Response API (/v1/responses) + Codex, + /// OpenAI Chat Completions API (/v1/chat/completions) + OpenAI, + /// Gemini API (/v1beta/models/*/generateContent) + Gemini, + /// Gemini CLI API (/v1internal/models/*/generateContent) + GeminiCli, + /// 未知格式 + Unknown, +} + +#[allow(dead_code)] +impl ClientFormat { + /// 从请求路径检测格式 + pub fn from_path(path: &str) -> Self { + if path.contains("/v1/messages") { + ClientFormat::Claude + } else if path.contains("/v1/responses") { + ClientFormat::Codex + } else if path.contains("/v1/chat/completions") { + ClientFormat::OpenAI + } else if path.contains("/v1internal/") && path.contains("generateContent") { + // Gemini CLI 使用 /v1internal/ 路径 + ClientFormat::GeminiCli + } else if (path.contains("/v1beta/") || path.contains("/v1/")) + && path.contains("generateContent") + { + // Gemini API 使用 /v1beta/ 或 /v1/ 路径 + ClientFormat::Gemini + } else if path.contains("generateContent") { + // 通用 Gemini 端点 + ClientFormat::Gemini + } else { + ClientFormat::Unknown + } + } + + /// 从请求体内容检测格式(回退方案) + pub fn from_body(body: &serde_json::Value) -> Self { + // Claude 格式特征: messages 数组 + model 字段 + 无 response_format + if body.get("messages").is_some() + && body.get("model").is_some() + && body.get("response_format").is_none() + && body.get("contents").is_none() + { + // 区分 Claude 和 OpenAI + if body.get("max_tokens").is_some() { + return ClientFormat::Claude; + } + return ClientFormat::OpenAI; + } + + // Codex 格式特征: input 字段 + if body.get("input").is_some() { + return ClientFormat::Codex; + } + + // Gemini 格式特征: contents 数组 + if body.get("contents").is_some() { + return ClientFormat::Gemini; + } + + ClientFormat::Unknown + } + + /// 转换为字符串 + pub fn as_str(&self) -> &'static str { + match self { + ClientFormat::Claude => "claude", + ClientFormat::Codex => "codex", + ClientFormat::OpenAI => "openai", + ClientFormat::Gemini => "gemini", + ClientFormat::GeminiCli => "gemini_cli", + ClientFormat::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for ClientFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// 代理会话 +/// +/// 包含请求全生命周期的上下文数据 +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ProxySession { + /// 唯一会话 ID + pub session_id: String, + /// 请求开始时间 + pub start_time: Instant, + /// HTTP 方法 + pub method: String, + /// 请求 URL + pub request_url: String, + /// User-Agent + pub user_agent: Option, + /// 客户端请求格式 + pub client_format: ClientFormat, + /// 选定的供应商 ID + pub provider_id: Option, + /// 模型名称 + pub model: Option, + /// 是否为流式请求 + pub is_streaming: bool, +} + +#[allow(dead_code)] +impl ProxySession { + /// 从请求创建会话 + pub fn from_request( + method: &str, + request_url: &str, + user_agent: Option<&str>, + body: Option<&serde_json::Value>, + ) -> Self { + // 检测客户端格式 + let mut client_format = ClientFormat::from_path(request_url); + if client_format == ClientFormat::Unknown { + if let Some(body) = body { + client_format = ClientFormat::from_body(body); + } + } + + // 检测是否为流式请求 + let is_streaming = body + .and_then(|b| b.get("stream")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // 提取模型名称 + let model = body + .and_then(|b| b.get("model")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Self { + session_id: Uuid::new_v4().to_string(), + start_time: Instant::now(), + method: method.to_string(), + request_url: request_url.to_string(), + user_agent: user_agent.map(|s| s.to_string()), + client_format, + provider_id: None, + model, + is_streaming, + } + } + + /// 设置供应商 ID + pub fn with_provider(mut self, provider_id: &str) -> Self { + self.provider_id = Some(provider_id.to_string()); + self + } + + /// 获取请求延迟(毫秒) + pub fn latency_ms(&self) -> u64 { + self.start_time.elapsed().as_millis() as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_client_format_from_path_claude() { + assert_eq!( + ClientFormat::from_path("/v1/messages"), + ClientFormat::Claude + ); + assert_eq!( + ClientFormat::from_path("/api/v1/messages"), + ClientFormat::Claude + ); + } + + #[test] + fn test_client_format_from_path_codex() { + assert_eq!( + ClientFormat::from_path("/v1/responses"), + ClientFormat::Codex + ); + } + + #[test] + fn test_client_format_from_path_openai() { + assert_eq!( + ClientFormat::from_path("/v1/chat/completions"), + ClientFormat::OpenAI + ); + } + + #[test] + fn test_client_format_from_path_gemini() { + assert_eq!( + ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"), + ClientFormat::Gemini + ); + } + + #[test] + fn test_client_format_from_path_gemini_cli() { + assert_eq!( + ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"), + ClientFormat::GeminiCli + ); + } + + #[test] + fn test_client_format_from_body_claude() { + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 1024 + }); + assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude); + } + + #[test] + fn test_client_format_from_body_codex() { + let body = json!({ + "input": "Write a function" + }); + assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex); + } + + #[test] + fn test_client_format_from_body_gemini() { + let body = json!({ + "contents": [{"parts": [{"text": "Hello"}]}] + }); + assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini); + } + + #[test] + fn test_session_id_uniqueness() { + let session1 = ProxySession::from_request("POST", "/v1/messages", None, None); + let session2 = ProxySession::from_request("POST", "/v1/messages", None, None); + assert_ne!(session1.session_id, session2.session_id); + } + + #[test] + fn test_session_from_request() { + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 1024, + "stream": true + }); + + let session = + ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body)); + + assert_eq!(session.method, "POST"); + assert_eq!(session.request_url, "/v1/messages"); + assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string())); + assert_eq!(session.client_format, ClientFormat::Claude); + assert_eq!(session.model, Some("claude-3-5-sonnet".to_string())); + assert!(session.is_streaming); + } + + #[test] + fn test_session_with_provider() { + let session = ProxySession::from_request("POST", "/v1/messages", None, None) + .with_provider("provider-123"); + + assert_eq!(session.provider_id, Some("provider-123".to_string())); + } + + #[test] + fn test_client_format_as_str() { + assert_eq!(ClientFormat::Claude.as_str(), "claude"); + assert_eq!(ClientFormat::Codex.as_str(), "codex"); + assert_eq!(ClientFormat::OpenAI.as_str(), "openai"); + assert_eq!(ClientFormat::Gemini.as_str(), "gemini"); + assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli"); + assert_eq!(ClientFormat::Unknown.as_str(), "unknown"); + } +} diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs new file mode 100644 index 000000000..722862977 --- /dev/null +++ b/src-tauri/src/proxy/types.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +/// 代理服务器配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + /// 是否启用代理服务 + pub enabled: bool, + /// 监听地址 + pub listen_address: String, + /// 监听端口 + pub listen_port: u16, + /// 最大重试次数 + pub max_retries: u8, + /// 请求超时时间(秒) + pub request_timeout: u64, + /// 是否启用日志 + pub enable_logging: bool, +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + enabled: false, + listen_address: "127.0.0.1".to_string(), + listen_port: 15721, // 使用较少占用的高位端口 + max_retries: 3, + request_timeout: 300, + enable_logging: true, + } + } +} + +/// 代理服务器状态 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProxyStatus { + /// 是否运行中 + pub running: bool, + /// 监听地址 + pub address: String, + /// 监听端口 + pub port: u16, + /// 活跃连接数 + pub active_connections: usize, + /// 总请求数 + pub total_requests: u64, + /// 成功请求数 + pub success_requests: u64, + /// 失败请求数 + pub failed_requests: u64, + /// 成功率 (0-100) + pub success_rate: f32, + /// 运行时间(秒) + pub uptime_seconds: u64, + /// 当前使用的Provider名称 + pub current_provider: Option, + /// 当前Provider的ID + pub current_provider_id: Option, + /// 最后一次请求时间 + pub last_request_at: Option, + /// 最后一次错误信息 + pub last_error: Option, + /// Provider故障转移次数 + pub failover_count: u64, + /// 当前活跃的代理目标列表 + #[serde(default)] + pub active_targets: Vec, +} + +/// 活跃的代理目标信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveTarget { + pub app_type: String, // "Claude" | "Codex" | "Gemini" + pub provider_name: String, + pub provider_id: String, +} + +/// 代理服务器信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyServerInfo { + pub address: String, + pub port: u16, + pub started_at: String, +} + +/// API 格式类型(预留,当前不需要格式转换) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ApiFormat { + Claude, + OpenAI, + Gemini, +} + +/// Provider健康状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderHealth { + pub provider_id: String, + pub app_type: String, + pub is_healthy: bool, + pub consecutive_failures: u32, + pub last_success_at: Option, + pub last_failure_at: Option, + pub last_error: Option, + pub updated_at: String, +} + +/// 使用统计记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyUsageRecord { + pub provider_id: String, + pub app_type: String, + pub endpoint: String, + pub request_tokens: Option, + pub response_tokens: Option, + pub status_code: u16, + pub latency_ms: u64, + pub error: Option, + pub timestamp: String, +} diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs new file mode 100644 index 000000000..7295e13ae --- /dev/null +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -0,0 +1,186 @@ +//! Cost Calculator - 计算 API 请求成本 +//! +//! 使用高精度 Decimal 类型避免浮点数精度问题 + +use super::parser::TokenUsage; +use rust_decimal::Decimal; +use std::str::FromStr; + +/// 成本明细 +#[derive(Debug, Clone)] +pub struct CostBreakdown { + pub input_cost: Decimal, + pub output_cost: Decimal, + pub cache_read_cost: Decimal, + pub cache_creation_cost: Decimal, + pub total_cost: Decimal, +} + +/// 模型定价信息 +#[derive(Debug, Clone)] +pub struct ModelPricing { + pub input_cost_per_million: Decimal, + pub output_cost_per_million: Decimal, + pub cache_read_cost_per_million: Decimal, + pub cache_creation_cost_per_million: Decimal, +} + +/// 成本计算器 +pub struct CostCalculator; + +impl CostCalculator { + /// 计算请求成本 + /// + /// # 参数 + /// - `usage`: Token 使用量 + /// - `pricing`: 模型定价 + /// - `cost_multiplier`: 成本倍数 (provider 自定义) + pub fn calculate( + usage: &TokenUsage, + pricing: &ModelPricing, + cost_multiplier: Decimal, + ) -> CostBreakdown { + let million = Decimal::from(1_000_000); + + let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million + / million + * cost_multiplier; + let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million + / million + * cost_multiplier; + let cache_read_cost = + Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million + * cost_multiplier; + let cache_creation_cost = Decimal::from(usage.cache_creation_tokens) + * pricing.cache_creation_cost_per_million + / million + * cost_multiplier; + + let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost; + + CostBreakdown { + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + } + } + + /// 尝试计算成本,如果模型未知则返回 None + pub fn try_calculate( + usage: &TokenUsage, + pricing: Option<&ModelPricing>, + cost_multiplier: Decimal, + ) -> Option { + pricing.map(|p| Self::calculate(usage, p, cost_multiplier)) + } +} + +impl ModelPricing { + /// 从字符串创建定价信息 + pub fn from_strings( + input: &str, + output: &str, + cache_read: &str, + cache_creation: &str, + ) -> Result { + Ok(Self { + input_cost_per_million: Decimal::from_str(input)?, + output_cost_per_million: Decimal::from_str(output)?, + cache_read_cost_per_million: Decimal::from_str(cache_read)?, + cache_creation_cost_per_million: Decimal::from_str(cache_creation)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cost_calculation() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 200, + cache_creation_tokens: 100, + model: None, + }; + + let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap(); + let multiplier = Decimal::from_str("1.0").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // input: 1000 * 3.0 / 1M = 0.003 + assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); + // output: 500 * 15.0 / 1M = 0.0075 + assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); + // cache_read: 200 * 0.3 / 1M = 0.00006 + assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap()); + // cache_creation: 100 * 3.75 / 1M = 0.000375 + assert_eq!( + cost.cache_creation_cost, + Decimal::from_str("0.000375").unwrap() + ); + // total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935 + assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap()); + } + + #[test] + fn test_cost_multiplier() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + model: None, + }; + + let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap(); + let multiplier = Decimal::from_str("1.5").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // input: 1000 * 3.0 / 1M * 1.5 = 0.0045 + assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap()); + assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap()); + } + + #[test] + fn test_unknown_model_handling() { + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + model: None, + }; + + let multiplier = Decimal::from_str("1.0").unwrap(); + let cost = CostCalculator::try_calculate(&usage, None, multiplier); + + assert!(cost.is_none()); + } + + #[test] + fn test_decimal_precision() { + let usage = TokenUsage { + input_tokens: 1, + output_tokens: 1, + cache_read_tokens: 1, + cache_creation_tokens: 1, + model: None, + }; + + let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap(); + let multiplier = Decimal::from_str("1.0").unwrap(); + + let cost = CostCalculator::calculate(&usage, &pricing, multiplier); + + // 验证高精度计算 + assert!(cost.total_cost > Decimal::ZERO); + assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位 + } +} diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs new file mode 100644 index 000000000..ce99b9bac --- /dev/null +++ b/src-tauri/src/proxy/usage/logger.rs @@ -0,0 +1,286 @@ +//! Usage Logger - 记录 API 请求使用情况 + +use super::calculator::{CostBreakdown, CostCalculator, ModelPricing}; +use super::parser::TokenUsage; +use crate::database::Database; +use crate::error::AppError; +use crate::services::usage_stats::find_model_pricing_row; +use rust_decimal::Decimal; +use std::time::SystemTime; + +/// 请求日志 +#[derive(Debug, Clone)] +pub struct RequestLog { + pub request_id: String, + pub provider_id: String, + pub app_type: String, + pub model: String, + pub usage: TokenUsage, + pub cost: Option, + pub latency_ms: u64, + pub first_token_ms: Option, + pub status_code: u16, + pub error_message: Option, + pub session_id: Option, + /// 供应商类型 (claude, claude_auth, codex, gemini, gemini_cli, openrouter) + pub provider_type: Option, + /// 是否为流式请求 + pub is_streaming: bool, + /// 成本倍数 + pub cost_multiplier: String, +} + +/// 使用量记录器 +pub struct UsageLogger<'a> { + db: &'a Database, +} + +impl<'a> UsageLogger<'a> { + pub fn new(db: &'a Database) -> Self { + Self { db } + } + + /// 记录成功的请求 + pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> { + let conn = crate::database::lock_conn!(self.db.conn); + + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = + if let Some(cost) = &log.cost { + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } else { + ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ) + }; + + let created_at = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", + rusqlite::params![ + log.request_id, + log.provider_id, + log.app_type, + log.model, + log.usage.input_tokens, + log.usage.output_tokens, + log.usage.cache_read_tokens, + log.usage.cache_creation_tokens, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + log.latency_ms as i64, + log.first_token_ms.map(|v| v as i64), + log.status_code as i64, + log.error_message, + log.session_id, + log.provider_type, + log.is_streaming as i64, + log.cost_multiplier, + created_at, + ], + ) + .map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?; + + Ok(()) + } + + /// 记录失败的请求 + #[allow(dead_code, clippy::too_many_arguments)] + pub fn log_error( + &self, + request_id: String, + provider_id: String, + app_type: String, + model: String, + status_code: u16, + error_message: String, + latency_ms: u64, + ) -> Result<(), AppError> { + let log = RequestLog { + request_id, + provider_id, + app_type, + model, + usage: TokenUsage::default(), + cost: None, + latency_ms, + first_token_ms: None, + status_code, + error_message: Some(error_message), + session_id: None, + provider_type: None, + is_streaming: false, + cost_multiplier: "1.0".to_string(), + }; + + self.log_request(&log) + } + + /// 获取模型定价 + pub fn get_model_pricing(&self, model_id: &str) -> Result, AppError> { + let conn = crate::database::lock_conn!(self.db.conn); + let row = find_model_pricing_row(&conn, model_id)?; + match row { + Some((input, output, cache_read, cache_creation)) => { + ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation) + .map(Some) + .map_err(|e| AppError::Database(format!("解析定价数据失败: {e}"))) + } + None => Ok(None), + } + } + + /// 计算并记录请求 + #[allow(clippy::too_many_arguments)] + pub fn log_with_calculation( + &self, + request_id: String, + provider_id: String, + app_type: String, + model: String, + usage: TokenUsage, + cost_multiplier: Decimal, + latency_ms: u64, + first_token_ms: Option, + status_code: u16, + session_id: Option, + provider_type: Option, + is_streaming: bool, + ) -> Result<(), AppError> { + let pricing = self.get_model_pricing(&model)?; + + if pricing.is_none() { + log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0"); + } + + let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier); + + let log = RequestLog { + request_id, + provider_id, + app_type, + model, + usage, + cost, + latency_ms, + first_token_ms, + status_code, + error_message: None, + session_id, + provider_type, + is_streaming, + cost_multiplier: cost_multiplier.to_string(), + }; + + self.log_request(&log) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_request() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试定价 + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('test-model', 'Test Model', '3.0', '15.0')", + [], + ) + .unwrap(); + } + + let logger = UsageLogger::new(&db); + + let usage = TokenUsage { + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + model: None, + }; + + logger.log_with_calculation( + "req-123".to_string(), + "provider-1".to_string(), + "claude".to_string(), + "test-model".to_string(), + usage, + Decimal::from(1), + 100, + None, + 200, + None, + Some("claude".to_string()), + false, + )?; + + // 验证记录已插入 + let conn = crate::database::lock_conn!(db.conn); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + Ok(()) + } + + #[test] + fn test_log_error() -> Result<(), AppError> { + let db = Database::memory()?; + let logger = UsageLogger::new(&db); + + logger.log_error( + "req-error".to_string(), + "provider-1".to_string(), + "claude".to_string(), + "unknown-model".to_string(), + 500, + "Internal Server Error".to_string(), + 50, + )?; + + // 验证错误记录已插入 + let conn = crate::database::lock_conn!(db.conn); + let (status, error): (i64, Option) = conn + .query_row( + "SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(status, 500); + assert_eq!(error, Some("Internal Server Error".to_string())); + Ok(()) + } +} diff --git a/src-tauri/src/proxy/usage/mod.rs b/src-tauri/src/proxy/usage/mod.rs new file mode 100644 index 000000000..052ec8e08 --- /dev/null +++ b/src-tauri/src/proxy/usage/mod.rs @@ -0,0 +1,15 @@ +//! Proxy Usage Tracking Module +//! +//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能 + +pub mod calculator; +pub mod logger; +pub mod parser; + +// 仅导出内部使用的类型,避免未使用警告 +#[allow(unused_imports)] +pub use calculator::{CostBreakdown, CostCalculator, ModelPricing}; +#[allow(unused_imports)] +pub use logger::{RequestLog, UsageLogger}; +#[allow(unused_imports)] +pub use parser::{ApiType, TokenUsage}; diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs new file mode 100644 index 000000000..950d6f669 --- /dev/null +++ b/src-tauri/src/proxy/usage/parser.rs @@ -0,0 +1,534 @@ +//! Response Parser - 从 API 响应中提取 token 使用量 +//! +//! 支持多种 API 格式: +//! - Claude API (非流式和流式) +//! - OpenRouter (OpenAI 格式) +//! - Codex API (非流式和流式) +//! - Gemini API (非流式和流式) + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Token 使用量统计 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, + /// 从响应中提取的实际模型名称(如果可用) + pub model: Option, +} + +/// API 类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ApiType { + Claude, + OpenRouter, + Codex, + Gemini, +} + +impl TokenUsage { + /// 从 Claude API 非流式响应解析 + pub fn from_claude_response(body: &Value) -> Option { + let usage = body.get("usage")?; + Some(Self { + input_tokens: usage.get("input_tokens")?.as_u64()? as u32, + output_tokens: usage.get("output_tokens")?.as_u64()? as u32, + cache_read_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + model: None, + }) + } + + /// 从 Claude API 流式响应解析 + #[allow(dead_code)] + pub fn from_claude_stream_events(events: &[Value]) -> Option { + let mut usage = Self::default(); + + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + match event_type { + "message_start" => { + if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { + // 从 message_start 获取 input_tokens(原生 Claude API) + if let Some(input) = + msg_usage.get("input_tokens").and_then(|v| v.as_u64()) + { + usage.input_tokens = input as u32; + } + usage.cache_read_tokens = msg_usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + as u32; + usage.cache_creation_tokens = msg_usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + as u32; + } + } + "message_delta" => { + if let Some(delta_usage) = event.get("usage") { + // 从 message_delta 获取 output_tokens + if let Some(output) = + delta_usage.get("output_tokens").and_then(|v| v.as_u64()) + { + usage.output_tokens = output as u32; + } + // OpenRouter 转换后的流式响应:input_tokens 也在 message_delta 中 + // 如果 message_start 中没有 input_tokens,则从 message_delta 获取 + if usage.input_tokens == 0 { + if let Some(input) = + delta_usage.get("input_tokens").and_then(|v| v.as_u64()) + { + usage.input_tokens = input as u32; + } + } + } + } + _ => {} + } + } + } + + if usage.input_tokens > 0 || usage.output_tokens > 0 { + Some(usage) + } else { + None + } + } + + /// 从 OpenRouter 响应解析 (OpenAI 格式) + #[allow(dead_code)] + pub fn from_openrouter_response(body: &Value) -> Option { + let usage = body.get("usage")?; + Some(Self { + input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32, + output_tokens: usage.get("completion_tokens")?.as_u64()? as u32, + cache_read_tokens: 0, + cache_creation_tokens: 0, + model: None, + }) + } + + /// 从 Codex API 非流式响应解析 + pub fn from_codex_response(body: &Value) -> Option { + let usage = body.get("usage"); + if usage.is_none() { + log::debug!( + "[Codex] 响应中没有 usage 字段,body keys: {:?}", + body.as_object().map(|o| o.keys().collect::>()) + ); + return None; + } + let usage = usage?; + + let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64()); + let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()); + + if input_tokens.is_none() || output_tokens.is_none() { + log::debug!("[Codex] usage 字段缺少 input_tokens 或 output_tokens,usage: {usage:?}"); + return None; + } + + Some(Self { + input_tokens: input_tokens? as u32, + output_tokens: output_tokens? as u32, + cache_read_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + model: None, + }) + } + + /// 从 Codex API 响应解析并调整 input_tokens + /// + /// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数 + /// 公式: adjusted_input = max(input_tokens - cached_tokens, 0) + #[allow(dead_code)] + pub fn from_codex_response_adjusted(body: &Value) -> Option { + let usage = body.get("usage")?; + let input_tokens = usage.get("input_tokens")?.as_u64()? as u32; + let output_tokens = usage.get("output_tokens")?.as_u64()? as u32; + + // 获取 cached_tokens (可能在 input_tokens_details 中) + let cached_tokens = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + // 调整 input_tokens: 减去 cached_tokens + let adjusted_input = input_tokens.saturating_sub(cached_tokens); + + Some(Self { + input_tokens: adjusted_input, + output_tokens, + cache_read_tokens: cached_tokens, + cache_creation_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + model: None, + }) + } + + /// 从 Codex API 流式响应解析 + #[allow(dead_code)] + pub fn from_codex_stream_events(events: &[Value]) -> Option { + log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len()); + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + log::debug!("[Codex] 事件类型: {event_type}"); + if event_type == "response.completed" { + if let Some(response) = event.get("response") { + log::debug!("[Codex] 找到 response.completed 事件,解析 usage"); + return Self::from_codex_response(response); + } + } + } + } + log::debug!("[Codex] 未找到 response.completed 事件"); + None + } + + /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens) + pub fn from_openai_response(body: &Value) -> Option { + let usage = body.get("usage")?; + + // OpenAI 使用 prompt_tokens 和 completion_tokens + let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64())?; + let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?; + + // 获取 cached_tokens (可能在 prompt_tokens_details 中) + let cached_tokens = usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + Some(Self { + input_tokens: prompt_tokens as u32, + output_tokens: completion_tokens as u32, + cache_read_tokens: cached_tokens, + cache_creation_tokens: 0, + model: None, + }) + } + + /// 从 OpenAI Chat Completions API 流式响应解析 + pub fn from_openai_stream_events(events: &[Value]) -> Option { + log::debug!("[Codex] 解析 OpenAI 流式事件,共 {} 个事件", events.len()); + // OpenAI 流式响应在最后一个 chunk 中包含 usage + for event in events.iter().rev() { + if let Some(usage) = event.get("usage") { + if !usage.is_null() { + log::debug!("[Codex] 找到 usage: {usage:?}"); + return Self::from_openai_response(event); + } + } + } + log::debug!("[Codex] 未找到 usage 信息"); + None + } + + /// 从 Gemini API 非流式响应解析 + pub fn from_gemini_response(body: &Value) -> Option { + let usage = body.get("usageMetadata")?; + // 提取实际使用的模型名称(modelVersion 字段) + let model = body + .get("modelVersion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Some(Self { + input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32, + output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32, + cache_read_tokens: usage + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cache_creation_tokens: 0, + model, + }) + } + + /// 从 Gemini API 流式响应解析 + #[allow(dead_code)] + pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option { + let mut total_input = 0u32; + let mut total_output = 0u32; + let mut total_cache_read = 0u32; + let mut model: Option = None; + + for chunk in chunks { + if let Some(usage) = chunk.get("usageMetadata") { + total_input = usage + .get("promptTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + total_output += usage + .get("candidatesTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + total_cache_read = usage + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + } + + // 提取实际使用的模型名称(modelVersion 字段) + if model.is_none() { + if let Some(model_version) = chunk.get("modelVersion").and_then(|v| v.as_str()) { + model = Some(model_version.to_string()); + } + } + } + + if total_input > 0 || total_output > 0 { + Some(Self { + input_tokens: total_input, + output_tokens: total_output, + cache_read_tokens: total_cache_read, + cache_creation_tokens: 0, + model, + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_claude_response_parsing() { + let response = json!({ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + }); + + let usage = TokenUsage::from_claude_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + } + + #[test] + fn test_claude_stream_parsing() { + let events = vec![ + json!({ + "type": "message_start", + "message": { + "usage": { + "input_tokens": 100, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + } + }), + json!({ + "type": "message_delta", + "usage": { + "output_tokens": 50 + } + }), + ]; + + let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + } + + #[test] + fn test_openrouter_response_parsing() { + let response = json!({ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50 + } + }); + + let usage = TokenUsage::from_openrouter_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 0); + assert_eq!(usage.cache_creation_tokens, 0); + } + + #[test] + fn test_gemini_response_parsing() { + let response = json!({ + "modelVersion": "gemini-3-pro-high", + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 50, + "cachedContentTokenCount": 20 + } + }); + + let usage = TokenUsage::from_gemini_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 0); + assert_eq!(usage.model, Some("gemini-3-pro-high".to_string())); + } + + #[test] + fn test_gemini_response_parsing_no_model() { + // 测试没有 modelVersion 字段的情况 + let response = json!({ + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 50, + "cachedContentTokenCount": 20 + } + }); + + let usage = TokenUsage::from_gemini_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 0); + assert_eq!(usage.model, None); + } + + #[test] + fn test_codex_response_adjusted() { + let response = json!({ + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 300 + } + } + }); + + let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap(); + // input_tokens 应该被调整: 1000 - 300 = 700 + assert_eq!(usage.input_tokens, 700); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 300); + } + + #[test] + fn test_codex_response_adjusted_no_cache() { + let response = json!({ + "usage": { + "input_tokens": 1000, + "output_tokens": 500 + } + }); + + let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap(); + // 没有 cached_tokens,input_tokens 保持不变 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 0); + } + + #[test] + fn test_codex_response_adjusted_saturating_sub() { + // 测试 cached_tokens > input_tokens 的边界情况 + let response = json!({ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "input_tokens_details": { + "cached_tokens": 200 + } + } + }); + + let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap(); + // saturating_sub 确保不会下溢 + assert_eq!(usage.input_tokens, 0); + assert_eq!(usage.cache_read_tokens, 200); + } + + #[test] + fn test_openrouter_stream_parsing() { + // 测试 OpenRouter 转换后的流式响应解析 + // OpenRouter 流式响应经过转换后,input_tokens 在 message_delta 中 + let events = vec![ + json!({ + "type": "message_start", + "message": { + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + } + }), + json!({ + "type": "message_delta", + "delta": { + "stop_reason": "end_turn" + }, + "usage": { + "input_tokens": 150, + "output_tokens": 75 + } + }), + ]; + + let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); + assert_eq!(usage.input_tokens, 150); + assert_eq!(usage.output_tokens, 75); + } + + #[test] + fn test_native_claude_stream_parsing() { + // 测试原生 Claude API 流式响应解析 + // 原生 Claude API 的 input_tokens 在 message_start 中 + let events = vec![ + json!({ + "type": "message_start", + "message": { + "usage": { + "input_tokens": 200, + "cache_read_input_tokens": 50 + } + } + }), + json!({ + "type": "message_delta", + "usage": { + "output_tokens": 100 + } + }), + ]; + + let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); + assert_eq!(usage.input_tokens, 200); + assert_eq!(usage.output_tokens, 100); + assert_eq!(usage.cache_read_tokens, 50); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 747a08652..2b3e7bcc6 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,14 +2,25 @@ pub mod config; pub mod env_checker; pub mod env_manager; pub mod mcp; +pub mod model_test; pub mod prompt; pub mod provider; +pub mod proxy; pub mod skill; pub mod speedtest; +pub mod usage_stats; pub use config::ConfigService; pub use mcp::McpService; +#[allow(unused_imports)] +pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService}; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; +pub use proxy::ProxyService; pub use skill::{Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; +#[allow(unused_imports)] +pub use usage_stats::{ + DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, + RequestLogDetail, UsageSummary, +}; diff --git a/src-tauri/src/services/model_test.rs b/src-tauri/src/services/model_test.rs new file mode 100644 index 000000000..a62dacf7b --- /dev/null +++ b/src-tauri/src/services/model_test.rs @@ -0,0 +1,510 @@ +//! 模型测试服务 +//! +//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑, +//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。 + +use crate::app_config::AppType; +use crate::database::Database; +use crate::error::AppError; +use crate::provider::Provider; +use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +/// 模型测试配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelTestConfig { + /// 默认测试模型(Claude) + pub claude_model: String, + /// 默认测试模型(Codex/OpenAI) + pub codex_model: String, + /// 默认测试模型(Gemini) + pub gemini_model: String, + /// 测试提示词 + pub test_prompt: String, + /// 超时时间(秒) + pub timeout_secs: u64, +} + +impl Default for ModelTestConfig { + fn default() -> Self { + Self { + claude_model: "claude-haiku-4-5-20251001".to_string(), + codex_model: "gpt-5.1-low".to_string(), + gemini_model: "gemini-3-pro-low".to_string(), + test_prompt: "ping".to_string(), + timeout_secs: 15, + } + } +} + +/// 模型测试结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelTestResult { + pub success: bool, + pub message: String, + pub response_time_ms: Option, + pub http_status: Option, + pub model_used: String, + pub tested_at: i64, +} + +/// 模型测试日志记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelTestLog { + pub id: i64, + pub provider_id: String, + pub provider_name: String, + pub app_type: String, + pub model: String, + pub prompt: String, + pub success: bool, + pub message: String, + pub response_time_ms: Option, + pub http_status: Option, + pub tested_at: i64, +} + +/// 模型测试服务 +pub struct ModelTestService; + +impl ModelTestService { + /// 测试单个供应商的模型可用性 + pub async fn test_provider( + app_type: &AppType, + provider: &Provider, + config: &ModelTestConfig, + ) -> Result { + let start = Instant::now(); + let adapter = get_adapter(app_type); + + // 构建 HTTP 客户端(独立于代理服务) + let client = Client::builder() + .timeout(Duration::from_secs(config.timeout_secs)) + .build() + .map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?; + + // 根据 AppType 选择测试模型 + let model = match app_type { + AppType::Claude => &config.claude_model, + AppType::Codex => &config.codex_model, + AppType::Gemini => &config.gemini_model, + }; + + let result = match app_type { + AppType::Claude => { + Self::test_claude( + &client, + provider, + adapter.as_ref(), + model, + &config.test_prompt, + ) + .await + } + AppType::Codex => { + Self::test_codex( + &client, + provider, + adapter.as_ref(), + model, + &config.test_prompt, + ) + .await + } + AppType::Gemini => { + Self::test_gemini( + &client, + provider, + adapter.as_ref(), + model, + &config.test_prompt, + ) + .await + } + }; + + let response_time = start.elapsed().as_millis() as u64; + let tested_at = chrono::Utc::now().timestamp(); + + match result { + Ok((status, msg)) => Ok(ModelTestResult { + success: true, + message: msg, + response_time_ms: Some(response_time), + http_status: Some(status), + model_used: model.clone(), + tested_at, + }), + Err(e) => Ok(ModelTestResult { + success: false, + message: e.to_string(), + response_time_ms: Some(response_time), + http_status: None, + model_used: model.clone(), + tested_at, + }), + } + } + + /// 测试 Claude (Anthropic Messages API) + async fn test_claude( + client: &Client, + provider: &Provider, + adapter: &dyn ProviderAdapter, + model: &str, + prompt: &str, + ) -> Result<(u16, String), AppError> { + let base_url = adapter + .extract_base_url(provider) + .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; + + let auth = adapter + .extract_auth(provider) + .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; + + // 智能拼接 URL,避免重复 /v1 + let base = base_url.trim_end_matches('/'); + let url = if base.ends_with("/v1") { + format!("{base}/messages") + } else { + format!("{base}/v1/messages") + }; + + let body = json!({ + "model": model, + "max_tokens": 1, + "messages": [{ + "role": "user", + "content": prompt + }] + }); + + let mut request = client.post(&url).json(&body); + request = Self::add_claude_auth(request, &auth); + + let response = request.send().await.map_err(|e| { + if e.is_timeout() { + AppError::Message("请求超时".to_string()) + } else if e.is_connect() { + AppError::Message(format!("连接失败: {e}")) + } else { + AppError::Message(e.to_string()) + } + })?; + + let status = response.status().as_u16(); + + if response.status().is_success() { + // 先获取文本,再尝试解析 JSON(兼容流式响应) + let text = response.text().await.unwrap_or_default(); + + // 尝试解析 JSON + if let Ok(data) = serde_json::from_str::(&text) { + if data.get("type").is_some() + || data.get("content").is_some() + || data.get("id").is_some() + { + return Ok((status, "模型测试成功".to_string())); + } + } + + // 即使无法解析 JSON,只要状态码是 200 就认为成功 + Ok((status, "模型测试成功".to_string())) + } else { + let error_text = response.text().await.unwrap_or_default(); + Err(AppError::Message(format!("HTTP {status}: {error_text}"))) + } + } + + /// 测试 Codex (OpenAI Chat Completions API) + async fn test_codex( + client: &Client, + provider: &Provider, + adapter: &dyn ProviderAdapter, + model: &str, + prompt: &str, + ) -> Result<(u16, String), AppError> { + let base_url = adapter + .extract_base_url(provider) + .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; + + let auth = adapter + .extract_auth(provider) + .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; + + // 智能拼接 URL,避免重复 /v1 + let base = base_url.trim_end_matches('/'); + let url = if base.ends_with("/v1") { + format!("{base}/chat/completions") + } else { + format!("{base}/v1/chat/completions") + }; + + let body = json!({ + "model": model, + "messages": [{ + "role": "user", + "content": prompt + }], + "max_tokens": 1, + "stream": false + }); + + let request = client + .post(&url) + .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("Content-Type", "application/json") + .json(&body); + + let response = request.send().await.map_err(|e| { + if e.is_timeout() { + AppError::Message("请求超时".to_string()) + } else if e.is_connect() { + AppError::Message(format!("连接失败: {e}")) + } else { + AppError::Message(e.to_string()) + } + })?; + + let status = response.status().as_u16(); + + if response.status().is_success() { + // 先获取文本,再尝试解析 JSON + let text = response.text().await.unwrap_or_default(); + + if let Ok(data) = serde_json::from_str::(&text) { + if data.get("choices").is_some() || data.get("id").is_some() { + return Ok((status, "模型测试成功".to_string())); + } + } + + // 即使无法解析 JSON,只要状态码是 200 就认为成功 + Ok((status, "模型测试成功".to_string())) + } else { + let error_text = response.text().await.unwrap_or_default(); + Err(AppError::Message(format!("HTTP {status}: {error_text}"))) + } + } + + /// 测试 Gemini (Google Generative AI API) + async fn test_gemini( + client: &Client, + provider: &Provider, + adapter: &dyn ProviderAdapter, + model: &str, + prompt: &str, + ) -> Result<(u16, String), AppError> { + let base_url = adapter + .extract_base_url(provider) + .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; + + let auth = adapter + .extract_auth(provider) + .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; + + let url = format!( + "{}/v1beta/models/{}:generateContent?key={}", + base_url.trim_end_matches('/'), + model, + auth.api_key + ); + + let body = json!({ + "contents": [{ + "parts": [{ + "text": prompt + }] + }], + "generationConfig": { + "maxOutputTokens": 1 + } + }); + + let request = client + .post(&url) + .header("Content-Type", "application/json") + .json(&body); + + let response = request.send().await.map_err(|e| { + if e.is_timeout() { + AppError::Message("请求超时".to_string()) + } else if e.is_connect() { + AppError::Message(format!("连接失败: {e}")) + } else { + AppError::Message(e.to_string()) + } + })?; + + let status = response.status().as_u16(); + + if response.status().is_success() { + let data: Value = response + .json() + .await + .map_err(|e| AppError::Message(format!("解析响应失败: {e}")))?; + + if data.get("candidates").is_some() { + Ok((status, "模型测试成功".to_string())) + } else { + Err(AppError::Message("响应格式异常".to_string())) + } + } else { + let error_text = response.text().await.unwrap_or_default(); + Err(AppError::Message(format!("HTTP {status}: {error_text}"))) + } + } + + /// 添加 Claude 认证头 + fn add_claude_auth( + request: reqwest::RequestBuilder, + auth: &AuthInfo, + ) -> reqwest::RequestBuilder { + request + .header("x-api-key", &auth.api_key) + .header("anthropic-version", "2023-06-01") + .header("Content-Type", "application/json") + } +} + +// ===== 数据库操作 ===== + +impl Database { + /// 保存模型测试日志 + pub fn save_model_test_log( + &self, + provider_id: &str, + provider_name: &str, + app_type: &str, + model: &str, + prompt: &str, + result: &ModelTestResult, + ) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?; + + conn.execute( + "INSERT INTO model_test_logs + (provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + provider_id, + provider_name, + app_type, + model, + prompt, + result.success, + result.message, + result.response_time_ms.map(|t| t as i64), + result.http_status.map(|s| s as i64), + result.tested_at, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(conn.last_insert_rowid()) + } + + /// 获取模型测试日志 + pub fn get_model_test_logs( + &self, + app_type: Option<&str>, + provider_id: Option<&str>, + limit: u32, + ) -> Result, AppError> { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?; + + let mut sql = String::from( + "SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at + FROM model_test_logs WHERE 1=1" + ); + + let mut params: Vec> = Vec::new(); + + if let Some(at) = app_type { + sql.push_str(" AND app_type = ?"); + params.push(Box::new(at.to_string())); + } + + if let Some(pid) = provider_id { + sql.push_str(" AND provider_id = ?"); + params.push(Box::new(pid.to_string())); + } + + sql.push_str(" ORDER BY tested_at DESC LIMIT ?"); + params.push(Box::new(limit as i64)); + + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| AppError::Database(e.to_string()))?; + + let logs = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(ModelTestLog { + id: row.get(0)?, + provider_id: row.get(1)?, + provider_name: row.get(2)?, + app_type: row.get(3)?, + model: row.get(4)?, + prompt: row.get(5)?, + success: row.get(6)?, + message: row.get(7)?, + response_time_ms: row.get(8)?, + http_status: row.get(9)?, + tested_at: row.get(10)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(logs) + } + + /// 获取模型测试配置 + pub fn get_model_test_config(&self) -> Result { + match self.get_setting("model_test_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Message(format!("解析模型测试配置失败: {e}"))), + None => Ok(ModelTestConfig::default()), + } + } + + /// 保存模型测试配置 + pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Message(format!("序列化模型测试配置失败: {e}")))?; + self.set_setting("model_test_config", &json) + } + + /// 清理旧的测试日志(保留最近 N 条) + pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?; + + let deleted = conn + .execute( + "DELETE FROM model_test_logs WHERE id NOT IN ( + SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ? + )", + rusqlite::params![keep_count as i64], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(deleted as u64) + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 8d4513c56..8e4c5f965 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -217,6 +217,18 @@ impl ProviderService { Ok(()) } + /// Set proxy target provider + pub fn set_proxy_target(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + // Check if provider exists + let providers = state.db.get_all_providers(app_type.as_str())?; + if !providers.contains_key(id) { + return Err(AppError::Message(format!("供应商 {id} 不存在"))); + } + + state.db.set_proxy_target_provider(app_type.as_str(), id)?; + Ok(()) + } + /// Sync current provider to live configuration (re-export) pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { sync_current_to_live(state) diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs new file mode 100644 index 000000000..97bf9de3a --- /dev/null +++ b/src-tauri/src/services/proxy.rs @@ -0,0 +1,153 @@ +//! 代理服务业务逻辑层 +//! +//! 提供代理服务器的启动、停止和配置管理 + +use crate::database::Database; +use crate::proxy::server::ProxyServer; +use crate::proxy::types::*; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub struct ProxyService { + db: Arc, + server: Arc>>, +} + +impl ProxyService { + pub fn new(db: Arc) -> Self { + Self { + db, + server: Arc::new(RwLock::new(None)), + } + } + + /// 启动代理服务器 + pub async fn start(&self) -> Result { + // 1. 获取配置 + let mut config = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}"))?; + + // 2. 确保配置启用(用户通过UI启动即表示希望启用) + config.enabled = true; + + // 3. 检查是否已在运行 + if self.server.read().await.is_some() { + return Err("代理服务已在运行中".to_string()); + } + + // 4. 创建并启动服务器 + let server = ProxyServer::new(config.clone(), self.db.clone()); + let info = server + .start() + .await + .map_err(|e| format!("启动代理服务器失败: {e}"))?; + + // 5. 保存服务器实例 + *self.server.write().await = Some(server); + + // 6. 持久化 enabled 状态 + self.db + .update_proxy_config(config) + .await + .map_err(|e| format!("保存代理配置失败: {e}"))?; + + log::info!("代理服务器已启动: {}:{}", info.address, info.port); + Ok(info) + } + + /// 停止代理服务器 + pub async fn stop(&self) -> Result<(), String> { + if let Some(server) = self.server.write().await.take() { + server + .stop() + .await + .map_err(|e| format!("停止代理服务器失败: {e}"))?; + log::info!("代理服务器已停止"); + Ok(()) + } else { + Err("代理服务器未运行".to_string()) + } + } + + /// 获取服务器状态 + pub async fn get_status(&self) -> Result { + if let Some(server) = self.server.read().await.as_ref() { + Ok(server.get_status().await) + } else { + // 服务器未运行时返回默认状态 + Ok(ProxyStatus { + running: false, + ..Default::default() + }) + } + } + + /// 获取代理配置 + pub async fn get_config(&self) -> Result { + self.db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}")) + } + + /// 更新代理配置 + pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> { + // 记录旧配置用于判定是否需要重启 + let previous = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("获取代理配置失败: {e}"))?; + + // 保存到数据库(保持 enabled 状态不变) + let mut new_config = config.clone(); + new_config.enabled = previous.enabled; + + self.db + .update_proxy_config(new_config.clone()) + .await + .map_err(|e| format!("保存代理配置失败: {e}"))?; + + // 检查服务器当前状态 + let mut server_guard = self.server.write().await; + if server_guard.is_none() { + return Ok(()); + } + + // 判断是否需要重启(地址或端口变更) + let require_restart = new_config.listen_address != previous.listen_address + || new_config.listen_port != previous.listen_port; + + if require_restart { + if let Some(server) = server_guard.take() { + server + .stop() + .await + .map_err(|e| format!("重启前停止代理服务器失败: {e}"))?; + } + + let new_server = ProxyServer::new(new_config, self.db.clone()); + new_server + .start() + .await + .map_err(|e| format!("重启代理服务器失败: {e}"))?; + + *server_guard = Some(new_server); + log::info!("代理配置已更新,服务器已自动重启应用最新配置"); + } else if let Some(server) = server_guard.as_ref() { + server.apply_runtime_config(&new_config).await; + log::info!("代理配置已实时应用,无需重启代理服务器"); + } + + Ok(()) + } + + /// 检查服务器是否正在运行 + pub async fn is_running(&self) -> bool { + self.server.read().await.is_some() + } +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs new file mode 100644 index 000000000..d679d7f19 --- /dev/null +++ b/src-tauri/src/services/usage_stats.rs @@ -0,0 +1,1084 @@ +//! 使用统计服务 +//! +//! 提供使用量数据的聚合查询功能 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use chrono::{Duration, Utc}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; + +/// 使用量汇总 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSummary { + pub total_requests: u64, + pub total_cost: String, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_cache_creation_tokens: u64, + pub total_cache_read_tokens: u64, + pub success_rate: f32, +} + +/// 每日统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyStats { + pub date: String, + pub request_count: u64, + pub total_cost: String, + pub total_tokens: u64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_cache_creation_tokens: u64, + pub total_cache_read_tokens: u64, +} + +/// Provider 统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderStats { + pub provider_id: String, + pub provider_name: String, + pub request_count: u64, + pub total_tokens: u64, + pub total_cost: String, + pub success_rate: f32, + pub avg_latency_ms: u64, +} + +/// 模型统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelStats { + pub model: String, + pub request_count: u64, + pub total_tokens: u64, + pub total_cost: String, + pub avg_cost_per_request: String, +} + +/// 请求日志过滤器 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogFilters { + pub app_type: Option, + pub provider_name: Option, + pub model: Option, + pub status_code: Option, + pub start_date: Option, + pub end_date: Option, +} + +/// 分页请求日志响应 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaginatedLogs { + pub data: Vec, + pub total: u32, + pub page: u32, + pub page_size: u32, +} + +/// 请求日志详情 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestLogDetail { + pub request_id: String, + pub provider_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_name: Option, + pub app_type: String, + pub model: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, + pub input_cost_usd: String, + pub output_cost_usd: String, + pub cache_read_cost_usd: String, + pub cache_creation_cost_usd: String, + pub total_cost_usd: String, + pub is_streaming: bool, + pub latency_ms: u64, + pub first_token_ms: Option, + pub duration_ms: Option, + pub status_code: u16, + pub error_message: Option, + pub created_at: i64, +} + +impl Database { + /// 获取使用量汇总 + pub fn get_usage_summary( + &self, + start_date: Option, + end_date: Option, + ) -> Result { + let conn = lock_conn!(self.conn); + + let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { + let mut conditions = Vec::new(); + let mut params = Vec::new(); + + if let Some(start) = start_date { + conditions.push("created_at >= ?"); + params.push(start); + } + if let Some(end) = end_date { + conditions.push("created_at <= ?"); + params.push(end); + } + + (format!("WHERE {}", conditions.join(" AND ")), params) + } else { + (String::new(), Vec::new()) + }; + + let sql = format!( + "SELECT + COUNT(*) as total_requests, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count + FROM proxy_request_logs + {where_clause}" + ); + + let result = conn.query_row(&sql, rusqlite::params_from_iter(params_vec), |row| { + let total_requests: i64 = row.get(0)?; + let total_cost: f64 = row.get(1)?; + let total_input_tokens: i64 = row.get(2)?; + let total_output_tokens: i64 = row.get(3)?; + let total_cache_creation_tokens: i64 = row.get(4)?; + let total_cache_read_tokens: i64 = row.get(5)?; + let success_count: i64 = row.get(6)?; + + let success_rate = if total_requests > 0 { + (success_count as f32 / total_requests as f32) * 100.0 + } else { + 0.0 + }; + + Ok(UsageSummary { + total_requests: total_requests as u64, + total_cost: format!("{total_cost:.6}"), + total_input_tokens: total_input_tokens as u64, + total_output_tokens: total_output_tokens as u64, + total_cache_creation_tokens: total_cache_creation_tokens as u64, + total_cache_read_tokens: total_cache_read_tokens as u64, + success_rate, + }) + })?; + + Ok(result) + } + + /// 获取每日趋势 + pub fn get_daily_trends(&self, days: u32) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + if days <= 1 { + let sql = "SELECT + strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= strftime('%s', 'now', '-1 day') + GROUP BY bucket + ORDER BY bucket ASC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + Ok(DailyStats { + date: row.get(0)?, + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }) + })?; + + let mut buckets: HashMap = HashMap::new(); + for row in rows { + let stat = row?; + buckets.insert(stat.date.clone(), stat); + } + + let mut stats = Vec::new(); + let today = Utc::now().date_naive(); + for hour in 0..24 { + let bucket = today + .and_hms_opt(hour, 0, 0) + .unwrap() + .format("%Y-%m-%dT%H:00:00Z") + .to_string(); + + if let Some(stat) = buckets.remove(&bucket) { + stats.push(stat); + } else { + stats.push(DailyStats { + date: bucket, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + } + } + Ok(stats) + } else { + let sql = "SELECT + date(created_at, 'unixepoch') as bucket, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= strftime('%s', 'now', ?) + GROUP BY bucket + ORDER BY bucket ASC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([format!("-{days} days")], |row| { + Ok(DailyStats { + date: row.get(0)?, + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }) + })?; + + let mut map = HashMap::new(); + for row in rows { + let stat = row?; + map.insert(stat.date.clone(), stat); + } + + let mut stats = Vec::new(); + let start_day = + Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64); + + for i in 0..days { + let day = start_day + Duration::days(i as i64); + let key = day.format("%Y-%m-%d").to_string(); + if let Some(stat) = map.remove(&key) { + stats.push(stat); + } else { + stats.push(DailyStats { + date: key, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + } + } + Ok(stats) + } + } + + /// 获取 Provider 统计 + pub fn get_provider_stats(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let sql = "SELECT + l.provider_id, + p.name as provider_name, + COUNT(*) as request_count, + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count, + COALESCE(AVG(l.latency_ms), 0) as avg_latency + FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + GROUP BY l.provider_id, l.app_type + ORDER BY total_cost DESC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + let request_count: i64 = row.get(2)?; + let success_count: i64 = row.get(5)?; + let success_rate = if request_count > 0 { + (success_count as f32 / request_count as f32) * 100.0 + } else { + 0.0 + }; + + Ok(ProviderStats { + provider_id: row.get(0)?, + provider_name: row + .get::<_, Option>(1)? + .unwrap_or_else(|| "Unknown".to_string()), + request_count: request_count as u64, + total_tokens: row.get::<_, i64>(3)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(4)?), + success_rate, + avg_latency_ms: row.get::<_, f64>(6)? as u64, + }) + })?; + + let mut stats = Vec::new(); + for row in rows { + stats.push(row?); + } + + Ok(stats) + } + + /// 获取模型统计 + pub fn get_model_stats(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let sql = "SELECT + model, + COUNT(*) as request_count, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost + FROM proxy_request_logs + GROUP BY model + ORDER BY total_cost DESC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + let request_count: i64 = row.get(1)?; + let total_cost: f64 = row.get(3)?; + let avg_cost = if request_count > 0 { + total_cost / request_count as f64 + } else { + 0.0 + }; + + Ok(ModelStats { + model: row.get(0)?, + request_count: request_count as u64, + total_tokens: row.get::<_, i64>(2)? as u64, + total_cost: format!("{total_cost:.6}"), + avg_cost_per_request: format!("{avg_cost:.6}"), + }) + })?; + + let mut stats = Vec::new(); + for row in rows { + stats.push(row?); + } + + Ok(stats) + } + + /// 获取请求日志列表(分页) + pub fn get_request_logs( + &self, + filters: &LogFilters, + page: u32, + page_size: u32, + ) -> Result { + let conn = lock_conn!(self.conn); + + let mut conditions = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref app_type) = filters.app_type { + conditions.push("l.app_type = ?"); + params.push(Box::new(app_type.clone())); + } + if let Some(ref provider_name) = filters.provider_name { + conditions.push("p.name LIKE ?"); + params.push(Box::new(format!("%{provider_name}%"))); + } + if let Some(ref model) = filters.model { + conditions.push("l.model LIKE ?"); + params.push(Box::new(format!("%{model}%"))); + } + if let Some(status) = filters.status_code { + conditions.push("l.status_code = ?"); + params.push(Box::new(status as i64)); + } + if let Some(start) = filters.start_date { + conditions.push("l.created_at >= ?"); + params.push(Box::new(start)); + } + if let Some(end) = filters.end_date { + conditions.push("l.created_at <= ?"); + params.push(Box::new(end)); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + // 获取总数 + let count_sql = format!( + "SELECT COUNT(*) FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {where_clause}" + ); + let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total: u32 = conn.query_row(&count_sql, count_params.as_slice(), |row| { + row.get::<_, i64>(0).map(|v| v as u32) + })?; + + // 获取数据 + let offset = page * page_size; + params.push(Box::new(page_size as i64)); + params.push(Box::new(offset as i64)); + + let sql = format!( + "SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, + l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens, + l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd, + l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms, + l.status_code, l.error_message, l.created_at + FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {where_clause} + ORDER BY l.created_at DESC + LIMIT ? OFFSET ?" + ); + + let mut stmt = conn.prepare(&sql)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(params_refs.as_slice(), |row| { + Ok(RequestLogDetail { + request_id: row.get(0)?, + provider_id: row.get(1)?, + provider_name: row.get(2)?, + app_type: row.get(3)?, + model: row.get(4)?, + input_tokens: row.get::<_, i64>(5)? as u32, + output_tokens: row.get::<_, i64>(6)? as u32, + cache_read_tokens: row.get::<_, i64>(7)? as u32, + cache_creation_tokens: row.get::<_, i64>(8)? as u32, + input_cost_usd: row.get(9)?, + output_cost_usd: row.get(10)?, + cache_read_cost_usd: row.get(11)?, + cache_creation_cost_usd: row.get(12)?, + total_cost_usd: row.get(13)?, + is_streaming: row.get::<_, i64>(14)? != 0, + latency_ms: row.get::<_, i64>(15)? as u64, + first_token_ms: row.get::<_, Option>(16)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(17)?.map(|v| v as u64), + status_code: row.get::<_, i64>(18)? as u16, + error_message: row.get(19)?, + created_at: row.get(20)?, + }) + })?; + + let mut logs = Vec::new(); + let mut provider_cache = HashMap::new(); + let mut pricing_cache = HashMap::new(); + + for row in rows { + let mut log = row?; + Self::maybe_backfill_log_costs( + &conn, + &mut log, + &mut provider_cache, + &mut pricing_cache, + )?; + logs.push(log); + } + + Ok(PaginatedLogs { + data: logs, + total, + page, + page_size, + }) + } + + /// 获取单个请求详情 + pub fn get_request_detail( + &self, + request_id: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let result = conn.query_row( + "SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + is_streaming, latency_ms, first_token_ms, duration_ms, + status_code, error_message, created_at + FROM proxy_request_logs l + LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + WHERE l.request_id = ?", + [request_id], + |row| { + Ok(RequestLogDetail { + request_id: row.get(0)?, + provider_id: row.get(1)?, + provider_name: row.get(2)?, + app_type: row.get(3)?, + model: row.get(4)?, + input_tokens: row.get::<_, i64>(5)? as u32, + output_tokens: row.get::<_, i64>(6)? as u32, + cache_read_tokens: row.get::<_, i64>(7)? as u32, + cache_creation_tokens: row.get::<_, i64>(8)? as u32, + input_cost_usd: row.get(9)?, + output_cost_usd: row.get(10)?, + cache_read_cost_usd: row.get(11)?, + cache_creation_cost_usd: row.get(12)?, + total_cost_usd: row.get(13)?, + is_streaming: row.get::<_, i64>(14)? != 0, + latency_ms: row.get::<_, i64>(15)? as u64, + first_token_ms: row.get::<_, Option>(16)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(17)?.map(|v| v as u64), + status_code: row.get::<_, i64>(18)? as u16, + error_message: row.get(19)?, + created_at: row.get(20)?, + }) + }, + ); + + match result { + Ok(mut detail) => { + let mut provider_cache = HashMap::new(); + let mut pricing_cache = HashMap::new(); + Self::maybe_backfill_log_costs( + &conn, + &mut detail, + &mut provider_cache, + &mut pricing_cache, + )?; + Ok(Some(detail)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 检查 Provider 使用限额 + pub fn check_provider_limits( + &self, + provider_id: &str, + app_type: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + // 获取 provider 的限额设置 + let (limit_daily, limit_monthly) = conn + .query_row( + "SELECT meta FROM providers WHERE id = ? AND app_type = ?", + params![provider_id, app_type], + |row| { + let meta_str: String = row.get(0)?; + Ok(meta_str) + }, + ) + .ok() + .and_then(|meta_str| serde_json::from_str::(&meta_str).ok()) + .map(|meta| { + let daily = meta + .get("limitDailyUsd") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + let monthly = meta + .get("limitMonthlyUsd") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + (daily, monthly) + }) + .unwrap_or((None, None)); + + // 计算今日使用量 + let daily_usage: f64 = conn + .query_row( + "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) + FROM proxy_request_logs + WHERE provider_id = ? AND app_type = ? + AND date(created_at, 'unixepoch') = date('now')", + params![provider_id, app_type], + |row| row.get(0), + ) + .unwrap_or(0.0); + + // 计算本月使用量 + let monthly_usage: f64 = conn + .query_row( + "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) + FROM proxy_request_logs + WHERE provider_id = ? AND app_type = ? + AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')", + params![provider_id, app_type], + |row| row.get(0), + ) + .unwrap_or(0.0); + + let daily_exceeded = limit_daily + .map(|limit| daily_usage >= limit) + .unwrap_or(false); + let monthly_exceeded = limit_monthly + .map(|limit| monthly_usage >= limit) + .unwrap_or(false); + + Ok(ProviderLimitStatus { + provider_id: provider_id.to_string(), + daily_usage: format!("{daily_usage:.6}"), + daily_limit: limit_daily.map(|l| format!("{l:.2}")), + daily_exceeded, + monthly_usage: format!("{monthly_usage:.6}"), + monthly_limit: limit_monthly.map(|l| format!("{l:.2}")), + monthly_exceeded, + }) + } + + /// 更新每日统计聚合 + /// + /// 在请求完成后调用,更新 usage_daily_stats 表 + #[allow(clippy::too_many_arguments)] + pub fn update_daily_stats( + &self, + provider_id: &str, + app_type: &str, + model: &str, + input_tokens: u32, + output_tokens: u32, + total_cost: &str, + is_success: bool, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let date = Utc::now().format("%Y-%m-%d").to_string(); + + // 使用 UPSERT 更新或插入统计 + conn.execute( + "INSERT INTO usage_daily_stats ( + date, provider_id, app_type, model, + request_count, total_input_tokens, total_output_tokens, + total_cost_usd, success_count, error_count + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(date, provider_id, app_type, model) DO UPDATE SET + request_count = request_count + 1, + total_input_tokens = total_input_tokens + ?5, + total_output_tokens = total_output_tokens + ?6, + total_cost_usd = CAST( + CAST(total_cost_usd AS REAL) + CAST(?7 AS REAL) AS TEXT + ), + success_count = success_count + ?8, + error_count = error_count + ?9", + params![ + date, + provider_id, + app_type, + model, + input_tokens, + output_tokens, + total_cost, + if is_success { 1 } else { 0 }, + if is_success { 0 } else { 1 }, + ], + ) + .map_err(|e| AppError::Database(format!("更新每日统计失败: {e}")))?; + + Ok(()) + } +} + +/// Provider 限额状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderLimitStatus { + pub provider_id: String, + pub daily_usage: String, + pub daily_limit: Option, + pub daily_exceeded: bool, + pub monthly_usage: String, + pub monthly_limit: Option, + pub monthly_exceeded: bool, +} + +#[derive(Clone)] +struct PricingInfo { + input: rust_decimal::Decimal, + output: rust_decimal::Decimal, + cache_read: rust_decimal::Decimal, + cache_creation: rust_decimal::Decimal, +} + +impl Database { + fn maybe_backfill_log_costs( + conn: &Connection, + log: &mut RequestLogDetail, + provider_cache: &mut HashMap<(String, String), rust_decimal::Decimal>, + pricing_cache: &mut HashMap, + ) -> Result<(), AppError> { + let total_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd) + .unwrap_or(rust_decimal::Decimal::ZERO); + let has_cost = total_cost > rust_decimal::Decimal::ZERO; + let has_usage = log.input_tokens > 0 + || log.output_tokens > 0 + || log.cache_read_tokens > 0 + || log.cache_creation_tokens > 0; + + if has_cost || !has_usage { + return Ok(()); + } + + let pricing = match Self::get_model_pricing_cached(conn, pricing_cache, &log.model)? { + Some(info) => info, + None => return Ok(()), + }; + let multiplier = Self::get_cost_multiplier_cached( + conn, + provider_cache, + &log.provider_id, + &log.app_type, + )?; + + let million = rust_decimal::Decimal::from(1_000_000u64); + let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input + / million + * multiplier; + let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output + / million + * multiplier; + let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64) + * pricing.cache_read + / million + * multiplier; + let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64) + * pricing.cache_creation + / million + * multiplier; + let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost; + + log.input_cost_usd = format!("{input_cost:.6}"); + log.output_cost_usd = format!("{output_cost:.6}"); + log.cache_read_cost_usd = format!("{cache_read_cost:.6}"); + log.cache_creation_cost_usd = format!("{cache_creation_cost:.6}"); + log.total_cost_usd = format!("{total_cost:.6}"); + + conn.execute( + "UPDATE proxy_request_logs + SET input_cost_usd = ?1, + output_cost_usd = ?2, + cache_read_cost_usd = ?3, + cache_creation_cost_usd = ?4, + total_cost_usd = ?5 + WHERE request_id = ?6", + params![ + log.input_cost_usd, + log.output_cost_usd, + log.cache_read_cost_usd, + log.cache_creation_cost_usd, + log.total_cost_usd, + log.request_id + ], + ) + .map_err(|e| AppError::Database(format!("更新请求成本失败: {e}")))?; + + Ok(()) + } + + fn get_cost_multiplier_cached( + conn: &Connection, + cache: &mut HashMap<(String, String), rust_decimal::Decimal>, + provider_id: &str, + app_type: &str, + ) -> Result { + let key = (provider_id.to_string(), app_type.to_string()); + if let Some(multiplier) = cache.get(&key) { + return Ok(*multiplier); + } + + let meta_json: Option = conn + .query_row( + "SELECT meta FROM providers WHERE id = ? AND app_type = ?", + params![provider_id, app_type], + |row| row.get(0), + ) + .optional() + .map_err(|e| AppError::Database(format!("查询 provider meta 失败: {e}")))?; + + let multiplier = meta_json + .and_then(|meta| serde_json::from_str::(&meta).ok()) + .and_then(|value| value.get("costMultiplier").cloned()) + .and_then(|val| { + val.as_str() + .and_then(|s| rust_decimal::Decimal::from_str(s).ok()) + }) + .unwrap_or(rust_decimal::Decimal::ONE); + + cache.insert(key, multiplier); + Ok(multiplier) + } + + fn get_model_pricing_cached( + conn: &Connection, + cache: &mut HashMap, + model: &str, + ) -> Result, AppError> { + if let Some(info) = cache.get(model) { + return Ok(Some(info.clone())); + } + + let row = find_model_pricing_row(conn, model)?; + let Some((input, output, cache_read, cache_creation)) = row else { + return Ok(None); + }; + + let pricing = PricingInfo { + input: rust_decimal::Decimal::from_str(&input) + .map_err(|e| AppError::Database(format!("解析输入价格失败: {e}")))?, + output: rust_decimal::Decimal::from_str(&output) + .map_err(|e| AppError::Database(format!("解析输出价格失败: {e}")))?, + cache_read: rust_decimal::Decimal::from_str(&cache_read) + .map_err(|e| AppError::Database(format!("解析缓存读取价格失败: {e}")))?, + cache_creation: rust_decimal::Decimal::from_str(&cache_creation) + .map_err(|e| AppError::Database(format!("解析缓存写入价格失败: {e}")))?, + }; + + cache.insert(model.to_string(), pricing.clone()); + Ok(Some(pricing)) + } +} + +/// 标准化模型名称:去除供应商前缀并将点号替换为短横线 +/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5 +fn normalize_model_id(model_id: &str) -> String { + // 1. 去除供应商前缀(如 anthropic/、openai/) + let stripped = if let Some(pos) = model_id.find('/') { + &model_id[pos + 1..] + } else { + model_id + }; + // 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5) + stripped.replace('.', "-") +} + +pub(crate) fn find_model_pricing_row( + conn: &Connection, + model_id: &str, +) -> Result, AppError> { + // 0. 标准化模型名称(去除前缀 + 点号转短横线) + // 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5 + let normalized = normalize_model_id(model_id); + + // 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称) + for id in [model_id, normalized.as_str()] { + let exact = conn + .query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + WHERE model_id = ?1", + [id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; + + if exact.is_some() { + if id != model_id { + log::info!("模型 {model_id} 标准化后精确匹配到: {id}"); + } + return Ok(exact); + } + } + + // 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku) + // 使用标准化后的名称进行后缀匹配 + let mut current = normalized; + while let Some(pos) = current.rfind('-') { + current = current[..pos].to_string(); + + let result = conn + .query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + WHERE model_id = ?1", + [¤t], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; + + if result.is_some() { + log::info!("模型 {model_id} 通过删除后缀匹配到: {current}"); + return Ok(result); + } + } + + log::warn!("模型 {model_id} 未找到定价信息,成本将记录为 0"); + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_usage_summary() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试数据 + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["req1", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["req2", "p1", "claude", "claude-3", 200, 100, "0.02", 150, 200, 2000], + )?; + } + + let summary = db.get_usage_summary(None, None)?; + assert_eq!(summary.total_requests, 2); + assert_eq!(summary.success_rate, 100.0); + + Ok(()) + } + + #[test] + fn test_get_model_stats() -> Result<(), AppError> { + let db = Database::memory()?; + + // 插入测试数据 + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "req1", + "p1", + "claude", + "claude-3-sonnet", + 100, + 50, + "0.01", + 100, + 200, + 1000 + ], + )?; + } + + let stats = db.get_model_stats()?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].model, "claude-3-sonnet"); + assert_eq!(stats[0].request_count, 1); + + Ok(()) + } + + #[test] + fn test_model_pricing_matching() -> Result<(), AppError> { + let db = Database::memory()?; + let conn = lock_conn!(db.conn); + + // 测试精确匹配 + let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?; + assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5"); + + // 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5) + let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?; + assert!( + result.is_some(), + "应该能匹配带前缀的模型 anthropic/claude-haiku-4.5" + ); + + // 测试带供应商前缀 + 点号的模型名称 + let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?; + assert!( + result.is_some(), + "应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5" + ); + + // 测试逐步删除后缀匹配 - 日期后缀 + let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-sonnet-4-5-20241022" + ); + + // 测试逐步删除后缀匹配 - 多个后缀 + let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview" + ); + + // 测试 GPT 模型 + let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?; + assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20"); + + // 测试 Gemini 模型 + let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 gemini-2.5-flash-exp" + ); + + // 测试 claude-sonnet-4-5 命名格式 + let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?; + assert!( + result.is_some(), + "应该能通过删除后缀匹配 claude-sonnet-4-5-20250929" + ); + + // 测试不存在的模型 + let result = find_model_pricing_row(&conn, "unknown-model-123")?; + assert!(result.is_none(), "不应该匹配不存在的模型"); + + Ok(()) + } +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 1cf26de55..9c626a5ec 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,14 +1,18 @@ use crate::database::Database; +use crate::services::ProxyService; use std::sync::Arc; /// 全局应用状态 pub struct AppState { pub db: Arc, + pub proxy_service: ProxyService, } impl AppState { /// 创建新的应用状态 pub fn new(db: Arc) -> Self { - Self { db } + let proxy_service = ProxyService::new(db.clone()); + + Self { db, proxy_service } } } diff --git a/src/App.tsx b/src/App.tsx index c0614b73a..ecc966bfa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -74,6 +74,7 @@ function App() { switchProvider, deleteProvider, saveUsageScript, + setProxyTarget, } = useProviderActions(activeApp); // 监听来自托盘菜单的切换事件 @@ -313,6 +314,7 @@ function App() { appId={activeApp} isLoading={isLoading} onSwitch={switchProvider} + onSetProxyTarget={setProxyTarget} onEdit={setEditingProvider} onDelete={setConfirmDelete} onDuplicate={handleDuplicateProvider} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index f55ceb676..bbda3c05e 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import { Provider, UsageScript } from "@/types"; +import { Provider, UsageScript, UsageData } from "@/types"; import { usageApi, type AppId } from "@/lib/api"; import JsonEditor from "./JsonEditor"; import * as prettier from "prettier/standalone"; @@ -220,7 +220,7 @@ const UsageScriptModal: React.FC = ({ ); if (result.success && result.data && result.data.length > 0) { const summary = result.data - .map((plan) => { + .map((plan: UsageData) => { const planInfo = plan.planName ? `[${plan.planName}]` : ""; return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`; }) diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 18df7bf03..a0a8dec5d 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -1,22 +1,35 @@ -import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react"; +import { + BarChart3, + Check, + Copy, + Edit, + Loader2, + Play, + TestTube2, + Trash2, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface ProviderActionsProps { isCurrent: boolean; + isTesting?: boolean; onSwitch: () => void; onEdit: () => void; onDuplicate: () => void; + onTest?: () => void; onConfigureUsage: () => void; onDelete: () => void; } export function ProviderActions({ isCurrent, + isTesting, onSwitch, onEdit, onDuplicate, + onTest, onConfigureUsage, onDelete, }: ProviderActionsProps) { @@ -70,6 +83,23 @@ export function ProviderActions({ + {onTest && ( + + )} + + + + + + {isRunning && status ? ( +
+
+
+

+ 服务地址 +

+
+ + http://{status.address}:{status.port} + + +
+
+ +
+

+ 当前代理 +

+ {status.active_targets && status.active_targets.length > 0 ? ( +
+ {status.active_targets.map((target) => ( +
+ + {target.app_type} + + + {target.provider_name} + +
+ ))} +
+ ) : status.current_provider ? ( +

+ 当前 Provider:{" "} + + {status.current_provider} + +

+ ) : ( +

+ 当前 Provider:等待首次请求… +

+ )} +
+
+ +
+ } + label="活跃连接" + value={status.active_connections} + /> + } + label="总请求数" + value={status.total_requests} + /> + } + label="成功率" + value={`${status.success_rate.toFixed(1)}%`} + variant={status.success_rate > 90 ? "success" : "warning"} + /> + } + label="运行时间" + value={formatUptime(status.uptime_seconds)} + /> +
+
+ ) : ( +
+
+ +
+

+ 代理服务已停止 +

+

+ 使用右上角开关即可启动服务 +

+
+ )} + + + + + ); +} + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + variant?: "default" | "success" | "warning"; +} + +function StatCard({ icon, label, value, variant = "default" }: StatCardProps) { + const variantStyles = { + default: "", + success: "border-green-500/40 bg-green-500/5", + warning: "border-yellow-500/40 bg-yellow-500/5", + }; + + return ( +
+
+ {icon} + + {label} + +
+

{value}

+
+ ); +} diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx new file mode 100644 index 000000000..572e529b0 --- /dev/null +++ b/src/components/proxy/ProxySettingsDialog.tsx @@ -0,0 +1,397 @@ +/** + * 代理服务设置对话框 + */ + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormDescription, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useProxyConfig } from "@/hooks/useProxyConfig"; +import { useEffect, useMemo } from "react"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import type { ProxyConfig } from "@/types/proxy"; + +// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理) +type ProxyConfigForm = Omit; + +const createProxyConfigSchema = (t: TFunction) => { + const requestTimeoutSchema = z + .number() + .min( + 0, + t("proxy.settings.validation.timeoutNonNegative", { + defaultValue: "超时时间不能为负数", + }), + ) + .max( + 600, + t("proxy.settings.validation.timeoutMax", { + defaultValue: "超时时间最多600秒", + }), + ) + .refine((value) => value === 0 || value >= 10, { + message: t("proxy.settings.validation.timeoutRange", { + defaultValue: "请输入 0 或 10-600 之间的数值", + }), + }); + + return z.object({ + listen_address: z.string().regex( + /^(\d{1,3}\.){3}\d{1,3}$/, + t("proxy.settings.validation.addressInvalid", { + defaultValue: "请输入有效的IP地址", + }), + ), + listen_port: z + .number() + .min( + 1024, + t("proxy.settings.validation.portMin", { + defaultValue: "端口必须大于1024", + }), + ) + .max( + 65535, + t("proxy.settings.validation.portMax", { + defaultValue: "端口必须小于65535", + }), + ), + max_retries: z + .number() + .min( + 0, + t("proxy.settings.validation.retryMin", { + defaultValue: "重试次数不能为负", + }), + ) + .max( + 10, + t("proxy.settings.validation.retryMax", { + defaultValue: "重试次数不能超过10", + }), + ), + request_timeout: requestTimeoutSchema, + enable_logging: z.boolean(), + }); +}; + +interface ProxySettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ProxySettingsDialog({ + open, + onOpenChange, +}: ProxySettingsDialogProps) { + const { config, isLoading, updateConfig, isUpdating } = useProxyConfig(); + const { t } = useTranslation(); + const schema = useMemo(() => createProxyConfigSchema(t), [t]); + + const closePanel = () => onOpenChange(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + listen_address: "127.0.0.1", + listen_port: 5000, + max_retries: 3, + request_timeout: 300, + enable_logging: true, + }, + }); + + // 当配置加载完成后更新表单 + useEffect(() => { + if (config) { + form.reset({ + ...config, + }); + } + }, [config, form]); + + const onSubmit = async (data: ProxyConfigForm) => { + try { + // 添加 enabled 字段(从当前配置中获取,保持不变) + const configToSave: ProxyConfig = { + ...data, + enabled: config?.enabled ?? true, + }; + await updateConfig(configToSave); + closePanel(); + } catch (error) { + console.error("Save config failed:", error); + } + }; + + const formId = "proxy-settings-form"; + + return ( + + + + + } + > +
+

+ {t("proxy.settings.description", { + defaultValue: + "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。", + })} +

+ + + + {t("proxy.settings.alert.autoApply", { + defaultValue: + "保存后将自动同步到正在运行的代理服务,无需手动重启。", + })} + + + +
+ +
+
+

+ {t("proxy.settings.basic.title", { + defaultValue: "基础设置", + })} +

+

+ {t("proxy.settings.basic.description", { + defaultValue: "配置代理服务监听的地址与端口。", + })} +

+
+ +
+ ( + + + {t("proxy.settings.fields.listenAddress.label", { + defaultValue: "监听地址", + })} + + + + + + {t("proxy.settings.fields.listenAddress.description", { + defaultValue: + "代理服务器监听的 IP 地址(推荐 127.0.0.1)", + })} + + + + )} + /> + + ( + + + {t("proxy.settings.fields.listenPort.label", { + defaultValue: "监听端口", + })} + + + + field.onChange(parseInt(e.target.value, 10) || 0) + } + placeholder={t( + "proxy.settings.fields.listenPort.placeholder", + { defaultValue: "5000" }, + )} + disabled={isLoading} + /> + + + {t("proxy.settings.fields.listenPort.description", { + defaultValue: + "代理服务器监听的端口号(1024 ~ 65535)", + })} + + + + )} + /> +
+
+ +
+
+

+ {t("proxy.settings.advanced.title", { + defaultValue: "高级参数", + })} +

+

+ {t("proxy.settings.advanced.description", { + defaultValue: "控制请求的稳定性和日志记录。", + })} +

+
+ +
+ ( + + + {t("proxy.settings.fields.maxRetries.label", { + defaultValue: "最大重试次数", + })} + + + + field.onChange(parseInt(e.target.value, 10) || 0) + } + placeholder={t( + "proxy.settings.fields.maxRetries.placeholder", + { defaultValue: "3" }, + )} + disabled={isLoading} + /> + + + {t("proxy.settings.fields.maxRetries.description", { + defaultValue: "请求失败时的重试次数(0 ~ 10)", + })} + + + + )} + /> + + ( + + + {t("proxy.settings.fields.requestTimeout.label", { + defaultValue: "请求超时(秒)", + })} + + + + field.onChange(parseInt(e.target.value, 10) || 0) + } + placeholder={t( + "proxy.settings.fields.requestTimeout.placeholder", + { defaultValue: "0(不限)或 300" }, + )} + disabled={isLoading} + /> + + + {t("proxy.settings.fields.requestTimeout.description", { + defaultValue: + "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)", + })} + + + + )} + /> +
+ + ( + +
+ + {t("proxy.settings.fields.enableLogging.label", { + defaultValue: "启用日志记录", + })} + + + {t("proxy.settings.fields.enableLogging.description", { + defaultValue: "记录所有代理请求,便于排查问题", + })} + +
+ + + +
+ )} + /> +
+
+ +
+
+ ); +} diff --git a/src/components/proxy/index.ts b/src/components/proxy/index.ts new file mode 100644 index 000000000..b645228d8 --- /dev/null +++ b/src/components/proxy/index.ts @@ -0,0 +1,6 @@ +/** + * 代理功能组件导出 + */ + +export { ProxyPanel } from "./ProxyPanel"; +export { ProxySettingsDialog } from "./ProxySettingsDialog"; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index dd1cee41e..a8f78c206 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -17,6 +17,10 @@ import { WindowSettings } from "@/components/settings/WindowSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { AboutSection } from "@/components/settings/AboutSection"; +import { ProxyPanel } from "@/components/proxy"; +import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; +import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; +import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -162,13 +166,16 @@ export function SettingsPage({ onValueChange={setActiveTab} className="flex flex-col h-full" > - + {t("settings.tabGeneral")} {t("settings.tabAdvanced")} + + {t("usage.title", "使用统计")} + {t("common.about")} @@ -205,6 +212,16 @@ export function SettingsPage({ onBrowseDirectory={browseDirectory} onResetDirectory={resetDirectory} /> + + {/* 代理服务面板 */} + + + {/* 模型定价配置 */} + + + {/* 模型测试配置 */} + + + + + + )} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 000000000..1df043680 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx new file mode 100644 index 000000000..331ae8ef0 --- /dev/null +++ b/src/components/usage/ModelStatsTable.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useModelStats } from "@/lib/query/usage"; + +export function ModelStatsTable() { + const { t } = useTranslation(); + const { data: stats, isLoading } = useModelStats(); + + if (isLoading) { + return
; + } + + return ( +
+ + + + {t("usage.model", "模型")} + + {t("usage.requests", "请求数")} + + + {t("usage.tokens", "Tokens")} + + + {t("usage.totalCost", "总成本")} + + + {t("usage.avgCost", "平均成本")} + + + + + {stats?.length === 0 ? ( + + + {t("usage.noData", "暂无数据")} + + + ) : ( + stats?.map((stat) => ( + + + {stat.model} + + + {stat.requestCount.toLocaleString()} + + + {stat.totalTokens.toLocaleString()} + + + ${parseFloat(stat.totalCost).toFixed(4)} + + + ${parseFloat(stat.avgCostPerRequest).toFixed(6)} + + + )) + )} + +
+
+ ); +} diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx new file mode 100644 index 000000000..1d880cdb0 --- /dev/null +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -0,0 +1,225 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + getModelTestConfig, + saveModelTestConfig, + type ModelTestConfig, +} from "@/lib/api/model-test"; + +export function ModelTestConfigPanel() { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [config, setConfig] = useState({ + claudeModel: "claude-haiku-4-5-20251001", + codexModel: "gpt-5.1-low", + geminiModel: "gemini-3-pro-low", + testPrompt: "ping", + timeoutSecs: 15, + }); + + useEffect(() => { + loadConfig(); + }, []); + + async function loadConfig() { + try { + setIsLoading(true); + setError(null); + const data = await getModelTestConfig(); + setConfig(data); + } catch (e) { + setError(String(e)); + } finally { + setIsLoading(false); + } + } + + async function handleSave() { + try { + setIsSaving(true); + await saveModelTestConfig(config); + toast.success(t("modelTest.configSaved", "模型测试配置已保存")); + } catch (e) { + toast.error( + t("modelTest.configSaveFailed", "保存失败") + ": " + String(e), + ); + } finally { + setIsSaving(false); + } + } + + if (isLoading) { + return ( + + setIsExpanded(!isExpanded)} + > +
+ + + {t("modelTest.configTitle", "模型测试配置")} + +
+
+
+ ); + } + + return ( + + setIsExpanded(!isExpanded)} + > +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {t("modelTest.configTitle", "模型测试配置")} + + {!isExpanded && ( + + {t( + "modelTest.configDesc", + "配置模型测试使用的默认模型和提示词", + )} + + )} +
+
+
+ + {isExpanded && ( + + {error && ( + + {error} + + )} + +
+
+ + + setConfig({ ...config, claudeModel: e.target.value }) + } + placeholder="claude-haiku-4-5-20251001" + /> +
+ +
+ + + setConfig({ ...config, codexModel: e.target.value }) + } + placeholder="gpt-5.1-low" + /> +
+ +
+ + + setConfig({ ...config, geminiModel: e.target.value }) + } + placeholder="gemini-3-pro-low" + /> +
+
+ +
+
+ + + setConfig({ ...config, testPrompt: e.target.value }) + } + placeholder="ping" + /> +

+ {t( + "modelTest.testPromptHint", + "发送给模型的测试消息,建议使用简短内容以减少 token 消耗", + )} +

+
+ +
+ + + setConfig({ + ...config, + timeoutSecs: parseInt(e.target.value) || 15, + }) + } + /> +
+
+ +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx new file mode 100644 index 000000000..fb49bf1c9 --- /dev/null +++ b/src/components/usage/PricingConfigPanel.tsx @@ -0,0 +1,289 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage"; +import { PricingEditModal } from "./PricingEditModal"; +import type { ModelPricing } from "@/types/usage"; +import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react"; + +export function PricingConfigPanel() { + const { t } = useTranslation(); + const { data: pricing, isLoading, error } = useModelPricing(); + const deleteMutation = useDeleteModelPricing(); + const [editingModel, setEditingModel] = useState(null); + const [isAddingNew, setIsAddingNew] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + const handleDelete = (modelId: string) => { + deleteMutation.mutate(modelId, { + onSuccess: () => { + setDeleteConfirm(null); + }, + }); + }; + + const handleAddNew = () => { + setIsAddingNew(true); + setEditingModel({ + modelId: "", + displayName: "", + inputCostPerMillion: "0", + outputCostPerMillion: "0", + cacheReadCostPerMillion: "0", + cacheCreationCostPerMillion: "0", + }); + }; + + if (isLoading) { + return ( + + setIsExpanded(!isExpanded)} + > +
+ + + {t("usage.modelPricing", "模型定价")} + +
+
+
+ ); + } + + if (error) { + return ( + + setIsExpanded(!isExpanded)} + > +
+ {isExpanded ? ( + + ) : ( + + )} + + {t("usage.modelPricing", "模型定价")} + +
+
+ {isExpanded && ( + + + + {t("usage.loadPricingError", "加载定价数据失败")}:{" "} + {String(error)} + + + + )} +
+ ); + } + + return ( + + setIsExpanded(!isExpanded)} + > +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {t("usage.modelPricing", "模型定价")} + {pricing && pricing.length > 0 && ( + + ({pricing.length}) + + )} + + {!isExpanded && ( + + {t( + "usage.modelPricingDesc", + "配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)", + )} + + )} +
+
+ +
+
+ + {isExpanded && ( + + {!pricing || pricing.length === 0 ? ( + + + {t( + "usage.noPricingData", + '暂无定价数据。点击"新增"添加模型定价配置。', + )} + + + ) : ( +
+ + + + {t("usage.model", "模型")} + {t("usage.displayName", "显示名称")} + + {t("usage.inputCost", "输入成本")} + + + {t("usage.outputCost", "输出成本")} + + + {t("usage.cacheReadCost", "缓存读取")} + + + {t("usage.cacheWriteCost", "缓存写入")} + + + {t("common.actions", "操作")} + + + + + {pricing.map((model) => ( + + + {model.modelId} + + {model.displayName} + + ${model.inputCostPerMillion} + + + ${model.outputCostPerMillion} + + + ${model.cacheReadCostPerMillion} + + + ${model.cacheCreationCostPerMillion} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ )} + + {editingModel && ( + { + setEditingModel(null); + setIsAddingNew(false); + }} + /> + )} + + setDeleteConfirm(null)} + > + + + + {t("usage.deleteConfirmTitle", "确认删除")} + + + {t( + "usage.deleteConfirmDesc", + "确定要删除此模型定价配置吗?此操作无法撤销。", + )} + + + + + + + + +
+ ); +} diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx new file mode 100644 index 000000000..c759ac01d --- /dev/null +++ b/src/components/usage/PricingEditModal.tsx @@ -0,0 +1,219 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useUpdateModelPricing } from "@/lib/query/usage"; +import type { ModelPricing } from "@/types/usage"; + +interface PricingEditModalProps { + model: ModelPricing; + isNew?: boolean; + onClose: () => void; +} + +export function PricingEditModal({ + model, + isNew = false, + onClose, +}: PricingEditModalProps) { + const { t } = useTranslation(); + const updatePricing = useUpdateModelPricing(); + + const [formData, setFormData] = useState({ + modelId: model.modelId, + displayName: model.displayName, + inputCost: model.inputCostPerMillion, + outputCost: model.outputCostPerMillion, + cacheReadCost: model.cacheReadCostPerMillion, + cacheCreationCost: model.cacheCreationCostPerMillion, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 验证模型 ID + if (isNew && !formData.modelId.trim()) { + toast.error(t("usage.modelIdRequired", "模型 ID 不能为空")); + return; + } + + // 验证非负数 + const values = [ + formData.inputCost, + formData.outputCost, + formData.cacheReadCost, + formData.cacheCreationCost, + ]; + + for (const value of values) { + const num = parseFloat(value); + if (isNaN(num) || num < 0) { + toast.error(t("usage.invalidPrice", "价格必须为非负数")); + return; + } + } + + try { + await updatePricing.mutateAsync({ + modelId: isNew ? formData.modelId : model.modelId, + displayName: formData.displayName, + inputCost: formData.inputCost, + outputCost: formData.outputCost, + cacheReadCost: formData.cacheReadCost, + cacheCreationCost: formData.cacheCreationCost, + }); + + toast.success( + isNew + ? t("usage.pricingAdded", "定价已添加") + : t("usage.pricingUpdated", "定价已更新"), + ); + + onClose(); + } catch (error) { + toast.error(String(error)); + } + }; + + return ( + + + + + {isNew + ? t("usage.addPricing", "新增定价") + : `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`} + + + +
+ {isNew && ( +
+ + + setFormData({ ...formData, modelId: e.target.value }) + } + placeholder="例如: claude-3-5-sonnet-20241022" + required + /> +
+ )} + +
+ + + setFormData({ ...formData, displayName: e.target.value }) + } + placeholder="例如: Claude 3.5 Sonnet" + required + /> +
+ +
+ + + setFormData({ ...formData, inputCost: e.target.value }) + } + required + /> +
+ +
+ + + setFormData({ ...formData, outputCost: e.target.value }) + } + required + /> +
+ +
+ + + setFormData({ ...formData, cacheReadCost: e.target.value }) + } + required + /> +
+ +
+ + + setFormData({ ...formData, cacheCreationCost: e.target.value }) + } + required + /> +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx new file mode 100644 index 000000000..e7233ec60 --- /dev/null +++ b/src/components/usage/ProviderStatsTable.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useProviderStats } from "@/lib/query/usage"; + +export function ProviderStatsTable() { + const { t } = useTranslation(); + const { data: stats, isLoading } = useProviderStats(); + + if (isLoading) { + return
; + } + + return ( +
+ + + + {t("usage.provider", "Provider")} + + {t("usage.requests", "请求数")} + + + {t("usage.tokens", "Tokens")} + + + {t("usage.cost", "成本")} + + + {t("usage.successRate", "成功率")} + + + {t("usage.avgLatency", "平均延迟")} + + + + + {stats?.length === 0 ? ( + + + {t("usage.noData", "暂无数据")} + + + ) : ( + stats?.map((stat) => ( + + + {stat.providerName} + + + {stat.requestCount.toLocaleString()} + + + {stat.totalTokens.toLocaleString()} + + + ${parseFloat(stat.totalCost).toFixed(4)} + + + {stat.successRate.toFixed(1)}% + + + {stat.avgLatencyMs}ms + + + )) + )} + +
+
+ ); +} diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx new file mode 100644 index 000000000..aa91ee510 --- /dev/null +++ b/src/components/usage/RequestDetailPanel.tsx @@ -0,0 +1,247 @@ +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useRequestDetail } from "@/lib/query/usage"; + +interface RequestDetailPanelProps { + requestId: string; + onClose: () => void; +} + +export function RequestDetailPanel({ + requestId, + onClose, +}: RequestDetailPanelProps) { + const { t } = useTranslation(); + const { data: request, isLoading } = useRequestDetail(requestId); + + if (isLoading) { + return ( + + +
+ +
+ ); + } + + if (!request) { + return ( + + + + {t("usage.requestDetail", "请求详情")} + +
+ {t("usage.requestNotFound", "请求未找到")} +
+
+
+ ); + } + + return ( + + + + {t("usage.requestDetail", "请求详情")} + + +
+ {/* 基本信息 */} +
+

+ {t("usage.basicInfo", "基本信息")} +

+
+
+
+ {t("usage.requestId", "请求ID")} +
+
{request.requestId}
+
+
+
+ {t("usage.time", "时间")} +
+
+ {new Date(request.createdAt * 1000).toLocaleString("zh-CN")} +
+
+
+
+ {t("usage.provider", "供应商")} +
+
+ + {request.providerName || t("usage.unknownProvider", "未知")} + + + {request.providerId} + +
+
+
+
+ {t("usage.appType", "应用类型")} +
+
{request.appType}
+
+
+
+ {t("usage.model", "模型")} +
+
{request.model}
+
+
+
+ {t("usage.status", "状态")} +
+
+ = 200 && request.statusCode < 300 + ? "bg-green-100 text-green-800" + : "bg-red-100 text-red-800" + }`} + > + {request.statusCode} + +
+
+
+
+ + {/* Token 使用量 */} +
+

+ {t("usage.tokenUsage", "Token 使用量")} +

+
+
+
+ {t("usage.inputTokens", "输入 Tokens")} +
+
+ {request.inputTokens.toLocaleString()} +
+
+
+
+ {t("usage.outputTokens", "输出 Tokens")} +
+
+ {request.outputTokens.toLocaleString()} +
+
+
+
+ {t("usage.cacheReadTokens", "缓存读取")} +
+
+ {request.cacheReadTokens.toLocaleString()} +
+
+
+
+ {t("usage.cacheCreationTokens", "缓存写入")} +
+
+ {request.cacheCreationTokens.toLocaleString()} +
+
+
+
+ {t("usage.totalTokens", "总计")} +
+
+ {( + request.inputTokens + request.outputTokens + ).toLocaleString()} +
+
+
+
+ + {/* 成本明细 */} +
+

+ {t("usage.costBreakdown", "成本明细")} +

+
+
+
+ {t("usage.inputCost", "输入成本")} +
+
+ ${parseFloat(request.inputCostUsd).toFixed(6)} +
+
+
+
+ {t("usage.outputCost", "输出成本")} +
+
+ ${parseFloat(request.outputCostUsd).toFixed(6)} +
+
+
+
+ {t("usage.cacheReadCost", "缓存读取成本")} +
+
+ ${parseFloat(request.cacheReadCostUsd).toFixed(6)} +
+
+
+
+ {t("usage.cacheCreationCost", "缓存写入成本")} +
+
+ ${parseFloat(request.cacheCreationCostUsd).toFixed(6)} +
+
+
+
+ {t("usage.totalCost", "总成本")} +
+
+ ${parseFloat(request.totalCostUsd).toFixed(6)} +
+
+
+
+ + {/* 性能信息 */} +
+

+ {t("usage.performance", "性能信息")} +

+
+
+
+ {t("usage.latency", "延迟")} +
+
{request.latencyMs}ms
+
+
+
+ + {/* 错误信息 */} + {request.errorMessage && ( +
+

+ {t("usage.errorMessage", "错误信息")} +

+

{request.errorMessage}

+
+ )} +
+
+
+ ); +} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx new file mode 100644 index 000000000..26dbc66b9 --- /dev/null +++ b/src/components/usage/RequestLogTable.tsx @@ -0,0 +1,400 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useRequestLogs, usageKeys } from "@/lib/query/usage"; +import { useQueryClient } from "@tanstack/react-query"; +import type { LogFilters } from "@/types/usage"; +import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; + +export function RequestLogTable() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + // 默认时间范围:过去24小时 + const getDefaultFilters = (): LogFilters => { + const now = Math.floor(Date.now() / 1000); + const oneDayAgo = now - 24 * 60 * 60; + return { startDate: oneDayAgo, endDate: now }; + }; + + const [filters, setFilters] = useState(getDefaultFilters); + const [tempFilters, setTempFilters] = useState(getDefaultFilters); + const [page, setPage] = useState(0); + const pageSize = 20; + + const { data: result, isLoading } = useRequestLogs(filters, page, pageSize); + + const logs = result?.data ?? []; + const total = result?.total ?? 0; + const totalPages = Math.ceil(total / pageSize); + + const handleSearch = () => { + setFilters(tempFilters); + setPage(0); + }; + + const handleReset = () => { + const defaults = getDefaultFilters(); + setTempFilters(defaults); + setFilters(defaults); + setPage(0); + }; + + const handleRefresh = () => { + queryClient.invalidateQueries({ + queryKey: usageKeys.logs(filters, page, pageSize), + }); + }; + + return ( +
+ {/* 筛选栏 */} +
+ + + + + + setTempFilters({ + ...tempFilters, + providerName: e.target.value || undefined, + }) + } + /> + + + setTempFilters({ + ...tempFilters, + model: e.target.value || undefined, + }) + } + /> + + + setTempFilters({ + ...tempFilters, + startDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> + + + setTempFilters({ + ...tempFilters, + endDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> + +
+ + + +
+
+ + {isLoading ? ( +
+ ) : ( + <> +
+ + + + {t("usage.time", "时间")} + {t("usage.provider", "供应商")} + + {t("usage.billingModel", "计费模型")} + + + {t("usage.inputTokens", "输入")} + + + {t("usage.outputTokens", "输出")} + + + {t("usage.cacheCreationTokens", "缓存写入")} + + + {t("usage.cacheReadTokens", "缓存读取")} + + + {t("usage.totalCost", "成本")} + + + {t("usage.timingInfo", "用时/首字")} + + {t("usage.status", "状态")} + + + + {logs.length === 0 ? ( + + + {t("usage.noData", "暂无数据")} + + + ) : ( + logs.map((log) => ( + + + {new Date(log.createdAt * 1000).toLocaleString("zh-CN")} + + + {log.providerName || + t("usage.unknownProvider", "未知供应商")} + + + {log.model} + + + {log.inputTokens.toLocaleString()} + + + {log.outputTokens.toLocaleString()} + + + {log.cacheCreationTokens.toLocaleString()} + + + {log.cacheReadTokens.toLocaleString()} + + + ${parseFloat(log.totalCostUsd).toFixed(6)} + + +
+ {(() => { + const durationSec = + (log.durationMs ?? log.latencyMs) / 1000; + const durationColor = + durationSec <= 5 + ? "bg-green-100 text-green-800" + : durationSec <= 120 + ? "bg-orange-100 text-orange-800" + : "bg-red-200 text-red-900"; + return ( + + {Math.round(durationSec)}s + + ); + })()} + {log.isStreaming && + log.firstTokenMs != null && + (() => { + const firstSec = log.firstTokenMs / 1000; + const firstColor = + firstSec <= 5 + ? "bg-green-100 text-green-800" + : firstSec <= 120 + ? "bg-orange-100 text-orange-800" + : "bg-red-200 text-red-900"; + return ( + + {firstSec.toFixed(1)}s + + ); + })()} + + {log.isStreaming + ? t("usage.stream", "流") + : t("usage.nonStream", "非流")} + +
+
+ + = 200 && log.statusCode < 300 + ? "bg-green-100 text-green-800" + : "bg-red-100 text-red-800" + }`} + > + {log.statusCode} + + +
+ )) + )} +
+
+
+ + {/* 分页控件 */} + {total > 0 && ( +
+ + {t("usage.totalRecords", "共 {{total}} 条记录", { total })} + +
+ + {/* 页码按钮 */} + {(() => { + const pages: (number | string)[] = []; + if (totalPages <= 7) { + for (let i = 0; i < totalPages; i++) pages.push(i); + } else { + pages.push(0); + if (page > 2) pages.push("..."); + for ( + let i = Math.max(1, page - 1); + i <= Math.min(totalPages - 2, page + 1); + i++ + ) { + pages.push(i); + } + if (page < totalPages - 3) pages.push("..."); + pages.push(totalPages - 1); + } + return pages.map((p, idx) => + typeof p === "string" ? ( + + ... + + ) : ( + + ), + ); + })()} + +
+
+ )} + + )} +
+ ); +} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx new file mode 100644 index 000000000..ef63ff0d6 --- /dev/null +++ b/src/components/usage/UsageDashboard.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card } from "@/components/ui/card"; +import { UsageSummaryCards } from "./UsageSummaryCards"; +import { UsageTrendChart } from "./UsageTrendChart"; +import { RequestLogTable } from "./RequestLogTable"; +import { ProviderStatsTable } from "./ProviderStatsTable"; +import { ModelStatsTable } from "./ModelStatsTable"; +import type { TimeRange } from "@/types/usage"; + +export function UsageDashboard() { + const { t } = useTranslation(); + const [timeRange, setTimeRange] = useState("1d"); + + const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; + + return ( +
+
+ +
+ + + + + + + + + + + {t("usage.requestLogs", "请求日志")} + + + {t("usage.providerStats", "Provider 统计")} + + + {t("usage.modelStats", "模型统计")} + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx new file mode 100644 index 000000000..12dc93d63 --- /dev/null +++ b/src/components/usage/UsageSummaryCards.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useUsageSummary } from "@/lib/query/usage"; + +interface UsageSummaryCardsProps { + days: number; +} + +export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { + const { t } = useTranslation(); + const endDate = Math.floor(Date.now() / 1000); + const startDate = endDate - days * 24 * 60 * 60; + + const { data: summary, isLoading } = useUsageSummary(startDate, endDate); + const totalRequests = summary?.totalRequests ?? 0; + const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4); + const totalInputTokens = summary?.totalInputTokens ?? 0; + const totalOutputTokens = summary?.totalOutputTokens ?? 0; + const totalTokens = totalInputTokens + totalOutputTokens; + const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; + const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; + const totalCacheTokens = cacheWriteTokens + cacheReadTokens; + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( + + +
+ + +
+ + + ))} +
+ ); + } + + return ( +
+ + + + {t("usage.totalRequests", "总请求数")} + + + +
+ {totalRequests.toLocaleString()} +
+
+
+ + + + + {t("usage.totalCost", "总成本")} + + + +
${totalCost}
+
+
+ + + + + {t("usage.totalTokens", "总 Token 数")} + + + +
+ {totalTokens.toLocaleString()} +
+
+
+ {t("usage.inputTokens", "输入")}:{" "} + {totalInputTokens.toLocaleString()} +
+
+ {t("usage.outputTokens", "输出")}:{" "} + {totalOutputTokens.toLocaleString()} +
+
+
+
+ + + + + {t("usage.cacheTokens", "缓存 Token")} + + + +
+ {totalCacheTokens.toLocaleString()} +
+
+
+ {t("usage.cacheWrite", "写入")}:{" "} + {cacheWriteTokens.toLocaleString()} +
+
+ {t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()} +
+
+
+
+
+ ); +} diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx new file mode 100644 index 000000000..2fa2f8363 --- /dev/null +++ b/src/components/usage/UsageTrendChart.tsx @@ -0,0 +1,160 @@ +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { useUsageTrends } from "@/lib/query/usage"; + +interface UsageTrendChartProps { + days: number; +} + +export function UsageTrendChart({ days }: UsageTrendChartProps) { + const { t } = useTranslation(); + const { data: trends, isLoading } = useUsageTrends(days); + + if (isLoading) { + return
; + } + + const isToday = days === 1; + const chartData = + trends?.map((stat) => { + const pointDate = new Date(stat.date); + return { + rawDate: stat.date, + label: isToday + ? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" }) + : pointDate.toLocaleDateString("zh-CN", { + month: "2-digit", + day: "2-digit", + }), + hour: pointDate.getHours(), + inputTokens: stat.totalInputTokens, + outputTokens: stat.totalOutputTokens, + cacheCreationTokens: stat.totalCacheCreationTokens, + cacheReadTokens: stat.totalCacheReadTokens, + cost: parseFloat(stat.totalCost), + }; + }) || []; + + const hourlyData = (() => { + if (!isToday) return chartData; + const map = new Map(); + chartData.forEach((point) => { + map.set(point.hour ?? 0, point); + }); + return Array.from({ length: 24 }, (_, hour) => { + const bucket = map.get(hour); + return { + label: `${hour.toString().padStart(2, "0")}:00`, + inputTokens: bucket?.inputTokens ?? 0, + outputTokens: bucket?.outputTokens ?? 0, + cacheCreationTokens: bucket?.cacheCreationTokens ?? 0, + cacheReadTokens: bucket?.cacheReadTokens ?? 0, + cost: bucket?.cost ?? 0, + }; + }); + })(); + + const displayData = isToday ? hourlyData : chartData; + + const rangeLabel = isToday + ? t("usage.rangeToday", "今天 (按小时)") + : days === 7 + ? t("usage.rangeLast7Days", "过去 7 天") + : t("usage.rangeLast30Days", "过去 30 天"); + + return ( +
+
+

+ {t("usage.trends", "使用趋势")} +

+

{rangeLabel}

+
+ + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index c1de539d5..a4b41d108 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -365,4 +365,22 @@ export const providerPresets: ProviderPreset[] = [ partnerPromotionKey: "packycode", // 促销信息 i18n key icon: "packycode", }, + { + name: "OpenRouter", + websiteUrl: "https://openrouter.ai", + apiKeyUrl: "https://openrouter.ai/keys", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.5", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.5", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.5", + }, + }, + category: "aggregator", + icon: "openrouter", + iconColor: "#6366F1", + }, ]; diff --git a/src/config/mcpPresets.ts b/src/config/mcpPresets.ts index e7514e641..f5cfd16d2 100644 --- a/src/config/mcpPresets.ts +++ b/src/config/mcpPresets.ts @@ -6,16 +6,19 @@ export type McpPreset = Omit; // 创建跨平台 npx 命令配置 // Windows 需要使用 cmd /c wrapper 来执行 npx.cmd // Mac/Linux 可以直接执行 npx -const createNpxCommand = (packageName: string, extraArgs: string[] = []): { command: string; args: string[] } => { +const createNpxCommand = ( + packageName: string, + extraArgs: string[] = [], +): { command: string; args: string[] } => { if (isWindows()) { return { - command: 'cmd', - args: ['/c', 'npx', ...extraArgs, packageName] + command: "cmd", + args: ["/c", "npx", ...extraArgs, packageName], }; } else { return { - command: 'npx', - args: [...extraArgs, packageName] + command: "npx", + args: [...extraArgs, packageName], }; } }; @@ -66,7 +69,9 @@ export const mcpPresets: McpPreset[] = [ tags: ["stdio", "thinking", "reasoning"], server: { type: "stdio", - ...createNpxCommand("@modelcontextprotocol/server-sequential-thinking", ["-y"]), + ...createNpxCommand("@modelcontextprotocol/server-sequential-thinking", [ + "-y", + ]), } as McpServerSpec, homepage: "https://github.com/modelcontextprotocol/servers", docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", diff --git a/src/hooks/useModelTest.ts b/src/hooks/useModelTest.ts new file mode 100644 index 000000000..f79e61faf --- /dev/null +++ b/src/hooks/useModelTest.ts @@ -0,0 +1,66 @@ +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { testProviderModel, type ModelTestResult } from "@/lib/api/model-test"; +import type { AppId } from "@/lib/api"; + +export function useModelTest(appId: AppId) { + const { t } = useTranslation(); + const [testingIds, setTestingIds] = useState>(new Set()); + + const testProvider = useCallback( + async ( + providerId: string, + providerName: string, + ): Promise => { + setTestingIds((prev) => new Set(prev).add(providerId)); + + try { + const result = await testProviderModel(appId, providerId); + + if (result.success) { + toast.success( + t("modelTest.success", { + name: providerName, + time: result.responseTimeMs, + defaultValue: `${providerName} 测试成功 (${result.responseTimeMs}ms)`, + }), + ); + } else { + toast.error( + t("modelTest.failed", { + name: providerName, + error: result.message, + defaultValue: `${providerName} 测试失败: ${result.message}`, + }), + ); + } + + return result; + } catch (e) { + toast.error( + t("modelTest.error", { + name: providerName, + error: String(e), + defaultValue: `${providerName} 测试出错: ${String(e)}`, + }), + ); + return null; + } finally { + setTestingIds((prev) => { + const next = new Set(prev); + next.delete(providerId); + return next; + }); + } + }, + [appId, t], + ); + + const isTesting = useCallback( + (providerId: string) => testingIds.has(providerId), + [testingIds], + ); + + return { testProvider, isTesting }; +} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index fb683a3f3..28b98f040 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -9,6 +9,7 @@ import { useUpdateProviderMutation, useDeleteProviderMutation, useSwitchProviderMutation, + useSetProxyTargetMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -24,6 +25,7 @@ export function useProviderActions(activeApp: AppId) { const updateProviderMutation = useUpdateProviderMutation(activeApp); const deleteProviderMutation = useDeleteProviderMutation(activeApp); const switchProviderMutation = useSwitchProviderMutation(activeApp); + const setProxyTargetMutation = useSetProxyTargetMutation(activeApp); // Claude 插件同步逻辑 const syncClaudePlugin = useCallback( @@ -91,6 +93,14 @@ export function useProviderActions(activeApp: AppId) { [switchProviderMutation, syncClaudePlugin], ); + // 设置代理目标 + const setProxyTarget = useCallback( + async (provider: Provider) => { + await setProxyTargetMutation.mutateAsync(provider.id); + }, + [setProxyTargetMutation], + ); + // 删除供应商 const deleteProvider = useCallback( async (id: string) => { @@ -136,12 +146,14 @@ export function useProviderActions(activeApp: AppId) { addProvider, updateProvider, switchProvider, + setProxyTarget, deleteProvider, saveUsageScript, isLoading: addProviderMutation.isPending || updateProviderMutation.isPending || deleteProviderMutation.isPending || - switchProviderMutation.isPending, + switchProviderMutation.isPending || + setProxyTargetMutation.isPending, }; } diff --git a/src/hooks/useProxyConfig.ts b/src/hooks/useProxyConfig.ts new file mode 100644 index 000000000..234017c22 --- /dev/null +++ b/src/hooks/useProxyConfig.ts @@ -0,0 +1,42 @@ +/** + * 代理配置管理 Hook + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import type { ProxyConfig } from "@/types/proxy"; + +/** + * 代理配置管理 + */ +export function useProxyConfig() { + const queryClient = useQueryClient(); + + // 查询配置 + const { data: config, isLoading } = useQuery({ + queryKey: ["proxyConfig"], + queryFn: () => invoke("get_proxy_config"), + }); + + // 更新配置 + const updateMutation = useMutation({ + mutationFn: (newConfig: ProxyConfig) => + invoke("update_proxy_config", { config: newConfig }), + onSuccess: () => { + toast.success("代理配置已保存"); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error(`保存失败: ${error.message}`); + }, + }); + + return { + config, + isLoading, + updateConfig: updateMutation.mutateAsync, + isUpdating: updateMutation.isPending, + }; +} diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts new file mode 100644 index 000000000..d201ee047 --- /dev/null +++ b/src/hooks/useProxyStatus.ts @@ -0,0 +1,70 @@ +/** + * 代理服务状态管理 Hook + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy"; + +/** + * 代理服务状态管理 + */ +export function useProxyStatus() { + const queryClient = useQueryClient(); + + // 查询状态(自动轮询) + const { data: status, isLoading } = useQuery({ + queryKey: ["proxyStatus"], + queryFn: () => invoke("get_proxy_status"), + // 仅在服务运行时轮询 + refetchInterval: (query) => (query.state.data?.running ? 2000 : false), + // 保持之前的数据,避免闪烁 + placeholderData: (previousData) => previousData, + }); + + // 启动服务器 + const startMutation = useMutation({ + mutationFn: () => invoke("start_proxy_server"), + onSuccess: (info) => { + toast.success(`代理服务已启动 - ${info.address}:${info.port}`); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error(`启动失败: ${error.message}`); + }, + }); + + // 停止服务器 + const stopMutation = useMutation({ + mutationFn: () => invoke("stop_proxy_server"), + onSuccess: () => { + toast.success("代理服务已停止"); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error(`停止失败: ${error.message}`); + }, + }); + + // 检查是否运行中 + const checkRunning = async () => { + try { + return await invoke("is_proxy_running"); + } catch { + return false; + } + }; + + return { + status, + isLoading, + isRunning: status?.running || false, + start: startMutation.mutateAsync, + stop: stopMutation.mutateAsync, + checkRunning, + isStarting: startMutation.isPending, + isStopping: stopMutation.isPending, + isPending: startMutation.isPending || stopMutation.isPending, + }; +} diff --git a/src/lib/api/model-test.ts b/src/lib/api/model-test.ts new file mode 100644 index 000000000..1e0e698e6 --- /dev/null +++ b/src/lib/api/model-test.ts @@ -0,0 +1,89 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { AppId } from "./types"; + +export interface ModelTestConfig { + claudeModel: string; + codexModel: string; + geminiModel: string; + testPrompt: string; + timeoutSecs: number; +} + +export interface ModelTestResult { + success: boolean; + message: string; + responseTimeMs?: number; + httpStatus?: number; + modelUsed: string; + testedAt: number; +} + +export interface ModelTestLog { + id: number; + providerId: string; + providerName: string; + appType: string; + model: string; + prompt: string; + success: boolean; + message: string; + responseTimeMs?: number; + httpStatus?: number; + testedAt: number; +} + +/** + * 测试单个供应商的模型可用性 + */ +export async function testProviderModel( + appType: AppId, + providerId: string, +): Promise { + return invoke("test_provider_model", { appType, providerId }); +} + +/** + * 批量测试所有供应商 + */ +export async function testAllProvidersModel( + appType: AppId, + proxyTargetsOnly: boolean = false, +): Promise> { + return invoke("test_all_providers_model", { appType, proxyTargetsOnly }); +} + +/** + * 获取模型测试配置 + */ +export async function getModelTestConfig(): Promise { + return invoke("get_model_test_config"); +} + +/** + * 保存模型测试配置 + */ +export async function saveModelTestConfig( + config: ModelTestConfig, +): Promise { + return invoke("save_model_test_config", { config }); +} + +/** + * 获取模型测试日志 + */ +export async function getModelTestLogs( + appType?: string, + providerId?: string, + limit?: number, +): Promise { + return invoke("get_model_test_logs", { appType, providerId, limit }); +} + +/** + * 清理旧的测试日志 + */ +export async function cleanupModelTestLogs( + keepCount?: number, +): Promise { + return invoke("cleanup_model_test_logs", { keepCount }); +} diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 14c8e115a..4da270598 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -38,6 +38,10 @@ export const providersApi = { return await invoke("switch_provider", { id, app: appId }); }, + async setProxyTarget(id: string, appId: AppId): Promise { + return await invoke("set_proxy_target_provider", { id, app: appId }); + }, + async importDefault(appId: AppId): Promise { return await invoke("import_default_config", { app: appId }); }, diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index c03f11d8e..7d6dcdbb6 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -1,33 +1,25 @@ import { invoke } from "@tauri-apps/api/core"; +import type { + UsageSummary, + DailyStats, + ProviderStats, + ModelStats, + RequestLog, + LogFilters, + ModelPricing, + ProviderLimitStatus, + PaginatedLogs, +} from "@/types/usage"; import type { UsageResult } from "@/types"; import type { AppId } from "./types"; -import i18n from "@/i18n"; export const usageApi = { - async query(providerId: string, appId: AppId): Promise { - try { - return await invoke("queryProviderUsage", { - providerId: providerId, - app: appId, - }); - } catch (error: unknown) { - // 提取错误消息:优先使用后端返回的错误信息 - const message = - typeof error === "string" - ? error - : error instanceof Error - ? error.message - : ""; - - // 如果没有错误消息,使用国际化的默认提示 - return { - success: false, - error: message || i18n.t("errors.usage_query_failed"), - }; - } + // Provider usage script methods + query: async (providerId: string, appId: AppId): Promise => { + return invoke("queryProviderUsage", { providerId, app: appId }); }, - async testScript( + testScript: async ( providerId: string, appId: AppId, scriptCode: string, @@ -36,30 +28,85 @@ export const usageApi = { baseUrl?: string, accessToken?: string, userId?: string, - ): Promise { - try { - return await invoke("testUsageScript", { - providerId: providerId, - app: appId, - scriptCode: scriptCode, - timeout: timeout, - apiKey: apiKey, - baseUrl: baseUrl, - accessToken: accessToken, - userId: userId, - }); - } catch (error: unknown) { - const message = - typeof error === "string" - ? error - : error instanceof Error - ? error.message - : ""; + ): Promise => { + return invoke("testUsageScript", { + providerId, + app: appId, + scriptCode, + timeout, + apiKey, + baseUrl, + accessToken, + userId, + }); + }, - return { - success: false, - error: message || i18n.t("errors.usage_query_failed"), - }; - } + // Proxy usage statistics methods + getUsageSummary: async ( + startDate?: number, + endDate?: number, + ): Promise => { + return invoke("get_usage_summary", { startDate, endDate }); + }, + + getUsageTrends: async (days: number): Promise => { + return invoke("get_usage_trends", { days }); + }, + + getProviderStats: async (): Promise => { + return invoke("get_provider_stats"); + }, + + getModelStats: async (): Promise => { + return invoke("get_model_stats"); + }, + + getRequestLogs: async ( + filters: LogFilters, + page: number = 0, + pageSize: number = 20, + ): Promise => { + return invoke("get_request_logs", { + filters, + page, + pageSize, + }); + }, + + getRequestDetail: async (requestId: string): Promise => { + return invoke("get_request_detail", { requestId }); + }, + + getModelPricing: async (): Promise => { + return invoke("get_model_pricing"); + }, + + updateModelPricing: async ( + modelId: string, + displayName: string, + inputCost: string, + outputCost: string, + cacheReadCost: string, + cacheCreationCost: string, + ): Promise => { + return invoke("update_model_pricing", { + modelId, + displayName, + inputCost, + outputCost, + cacheReadCost, + cacheCreationCost, + }); + }, + + deleteModelPricing: async (modelId: string): Promise => { + return invoke("delete_model_pricing", { modelId }); + }, + + checkProviderLimits: async ( + providerId: string, + appType: string, + ): Promise => { + return invoke("check_provider_limits", { providerId, appType }); }, }; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 061bdb098..8cdddacf9 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -176,6 +176,34 @@ export const useSwitchProviderMutation = (appId: AppId) => { }); }; +export const useSetProxyTargetMutation = (appId: AppId) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (providerId: string) => { + return await providersApi.setProxyTarget(providerId, appId); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); + toast.success( + t("notifications.proxyTargetSet", { + defaultValue: "已设置代理目标", + }), + ); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error( + t("notifications.setProxyTargetFailed", { + defaultValue: "设置代理目标失败: {{error}}", + error: detail, + }), + ); + }, + }); +}; + export const useSaveSettingsMutation = () => { const queryClient = useQueryClient(); diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts new file mode 100644 index 000000000..176fc3769 --- /dev/null +++ b/src/lib/query/usage.ts @@ -0,0 +1,120 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { usageApi } from "@/lib/api/usage"; +import type { LogFilters } from "@/types/usage"; + +// Query keys +export const usageKeys = { + all: ["usage"] as const, + summary: (startDate?: number, endDate?: number) => + [...usageKeys.all, "summary", startDate, endDate] as const, + trends: (days: number) => [...usageKeys.all, "trends", days] as const, + providerStats: () => [...usageKeys.all, "provider-stats"] as const, + modelStats: () => [...usageKeys.all, "model-stats"] as const, + logs: (filters: LogFilters, page: number, pageSize: number) => + [...usageKeys.all, "logs", filters, page, pageSize] as const, + detail: (requestId: string) => + [...usageKeys.all, "detail", requestId] as const, + pricing: () => [...usageKeys.all, "pricing"] as const, + limits: (providerId: string, appType: string) => + [...usageKeys.all, "limits", providerId, appType] as const, +}; + +// Hooks +export function useUsageSummary(startDate?: number, endDate?: number) { + return useQuery({ + queryKey: usageKeys.summary(startDate, endDate), + queryFn: () => usageApi.getUsageSummary(startDate, endDate), + }); +} + +export function useUsageTrends(days: number) { + return useQuery({ + queryKey: usageKeys.trends(days), + queryFn: () => usageApi.getUsageTrends(days), + }); +} + +export function useProviderStats() { + return useQuery({ + queryKey: usageKeys.providerStats(), + queryFn: usageApi.getProviderStats, + }); +} + +export function useModelStats() { + return useQuery({ + queryKey: usageKeys.modelStats(), + queryFn: usageApi.getModelStats, + }); +} + +export function useRequestLogs( + filters: LogFilters, + page: number = 0, + pageSize: number = 20, +) { + return useQuery({ + queryKey: usageKeys.logs(filters, page, pageSize), + queryFn: () => usageApi.getRequestLogs(filters, page, pageSize), + }); +} + +export function useRequestDetail(requestId: string) { + return useQuery({ + queryKey: usageKeys.detail(requestId), + queryFn: () => usageApi.getRequestDetail(requestId), + enabled: !!requestId, + }); +} + +export function useModelPricing() { + return useQuery({ + queryKey: usageKeys.pricing(), + queryFn: usageApi.getModelPricing, + }); +} + +export function useProviderLimits(providerId: string, appType: string) { + return useQuery({ + queryKey: usageKeys.limits(providerId, appType), + queryFn: () => usageApi.checkProviderLimits(providerId, appType), + enabled: !!providerId && !!appType, + }); +} + +export function useUpdateModelPricing() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { + modelId: string; + displayName: string; + inputCost: string; + outputCost: string; + cacheReadCost: string; + cacheCreationCost: string; + }) => + usageApi.updateModelPricing( + params.modelId, + params.displayName, + params.inputCost, + params.outputCost, + params.cacheReadCost, + params.cacheCreationCost, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: usageKeys.pricing() }); + }, + }); +} + +export function useDeleteModelPricing() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: usageKeys.pricing() }); + }, + }); +} diff --git a/src/types.ts b/src/types.ts index bdf46fcf9..570d7a2d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export interface Provider { // 图标配置 icon?: string; // 图标名称(如 "openai", "anthropic") iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E") + // 新增:是否为代理目标 + isProxyTarget?: boolean; } export interface AppConfig { diff --git a/src/types/proxy.ts b/src/types/proxy.ts new file mode 100644 index 000000000..83762c413 --- /dev/null +++ b/src/types/proxy.ts @@ -0,0 +1,61 @@ +export interface ProxyConfig { + enabled: boolean; + listen_address: string; + listen_port: number; + max_retries: number; + request_timeout: number; + enable_logging: boolean; +} + +export interface ProxyStatus { + running: boolean; + address: string; + port: number; + active_connections: number; + total_requests: number; + success_requests: number; + failed_requests: number; + success_rate: number; + uptime_seconds: number; + current_provider: string | null; + current_provider_id: string | null; + last_request_at: string | null; + last_error: string | null; + failover_count: number; + active_targets?: ActiveTarget[]; +} + +export interface ActiveTarget { + app_type: string; + provider_name: string; + provider_id: string; +} + +export interface ProxyServerInfo { + address: string; + port: number; + started_at: string; +} + +export interface ProviderHealth { + provider_id: string; + app_type: string; + is_healthy: boolean; + consecutive_failures: number; + last_success_at: string | null; + last_failure_at: string | null; + last_error: string | null; + updated_at: string; +} + +export interface ProxyUsageRecord { + provider_id: string; + app_type: string; + endpoint: string; + request_tokens: number | null; + response_tokens: number | null; + status_code: number; + latency_ms: number; + error: string | null; + timestamp: string; +} diff --git a/src/types/usage.ts b/src/types/usage.ts new file mode 100644 index 000000000..687fdfb2b --- /dev/null +++ b/src/types/usage.ts @@ -0,0 +1,114 @@ +// 使用统计相关类型定义 + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +export interface RequestLog { + requestId: string; + providerId: string; + providerName?: string; + appType: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + inputCostUsd: string; + outputCostUsd: string; + cacheReadCostUsd: string; + cacheCreationCostUsd: string; + totalCostUsd: string; + isStreaming: boolean; + latencyMs: number; + firstTokenMs?: number; + durationMs?: number; + statusCode: number; + errorMessage?: string; + createdAt: number; +} + +export interface PaginatedLogs { + data: RequestLog[]; + total: number; + page: number; + pageSize: number; +} + +export interface ModelPricing { + modelId: string; + displayName: string; + inputCostPerMillion: string; + outputCostPerMillion: string; + cacheReadCostPerMillion: string; + cacheCreationCostPerMillion: string; +} + +export interface UsageSummary { + totalRequests: number; + totalCost: string; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheCreationTokens: number; + totalCacheReadTokens: number; + successRate: number; +} + +export interface DailyStats { + date: string; + requestCount: number; + totalCost: string; + totalTokens: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheCreationTokens: number; + totalCacheReadTokens: number; +} + +export interface ProviderStats { + providerId: string; + providerName: string; + requestCount: number; + totalTokens: number; + totalCost: string; + successRate: number; + avgLatencyMs: number; +} + +export interface ModelStats { + model: string; + requestCount: number; + totalTokens: number; + totalCost: string; + avgCostPerRequest: string; +} + +export interface LogFilters { + appType?: string; + providerName?: string; + model?: string; + statusCode?: number; + startDate?: number; + endDate?: number; +} + +export interface ProviderLimitStatus { + providerId: string; + dailyUsage: string; + dailyLimit?: string; + dailyExceeded: boolean; + monthlyUsage: string; + monthlyLimit?: string; + monthlyExceeded: boolean; +} + +export type TimeRange = "1d" | "7d" | "30d"; + +export interface StatsFilters { + timeRange: TimeRange; + providerId?: string; + appType?: string; +} diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index 2b3f8205b..b53dd52e2 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -125,6 +125,7 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} + onSetProxyTarget={vi.fn()} isLoading />, ); @@ -153,6 +154,7 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} + onSetProxyTarget={vi.fn()} onCreate={handleCreate} />, ); @@ -193,6 +195,7 @@ describe("ProviderList Component", () => { onDuplicate={handleDuplicate} onConfigureUsage={handleUsage} onOpenWebsite={handleOpenWebsite} + onSetProxyTarget={vi.fn()} />, ); @@ -207,12 +210,12 @@ describe("ProviderList Component", () => { // Drag attributes from useSortable expect( providerCardRenderSpy.mock.calls[0][0].dragHandleProps?.attributes[ - "data-dnd-id" + "data-dnd-id" ], ).toBe("b"); expect( providerCardRenderSpy.mock.calls[1][0].dragHandleProps?.attributes[ - "data-dnd-id" + "data-dnd-id" ], ).toBe("a");