Compare commits

...

267 Commits

Author SHA1 Message Date
CaIon
f20b558e22 fix: correct request mode assignment logic in adaptor 2025-07-30 23:32:20 +08:00
CaIon
54447bf227 fix: remove debug print statement 2025-07-30 23:29:45 +08:00
CaIon
fc09051d8b fix: 修复缓存开启下自动禁用失效 2025-07-30 23:26:09 +08:00
Xyfacai
1f5ef24ecd feat: 显式指定 error 跳过重试 2025-07-30 22:35:31 +08:00
creamlike1024
b1faf42529 Merge branch 'RedwindA-fix/gemini-native-sse' into alpha 2025-07-30 20:37:10 +08:00
creamlike1024
6a85206e32 Merge branch 'fix/gemini-native-sse' of github.com:RedwindA/new-api into RedwindA-fix/gemini-native-sse 2025-07-30 20:34:12 +08:00
CaIon
e3d3e697d3 fix: WriteContentType panic 2025-07-30 20:31:51 +08:00
IcedTangerine
db9b333930 Merge pull request #1405 from RedwindA/fix/gemini-nothinking-handler
fix: improve gemini nothinking handler
2025-07-30 20:31:26 +08:00
CaIon
f7b284ad73 feat: 错误内容脱敏 2025-07-30 19:08:35 +08:00
CaIon
e1970e8a66 Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-30 18:39:32 +08:00
CaIon
0cd93d67ff fix: auto ban 2025-07-30 18:39:19 +08:00
IcedTangerine
6e806e21bd Merge pull request #1472 from QuantumNous/revert-1385-patch-1
Revert 1385 patch 1
2025-07-30 12:19:57 +08:00
IcedTangerine
a8462c1b70 Revert "Update relay-claude.go" 2025-07-30 12:17:56 +08:00
IcedTangerine
706ea8b649 Merge pull request #1385 from QingyeSC/patch-1
Update claude topP argument
2025-07-30 00:12:57 +08:00
CaIon
95d46d1dfc fix: auto ban 2025-07-29 23:08:16 +08:00
CaIon
010f27678d fix: auto ban 2025-07-29 15:20:08 +08:00
t0ng7u
d87117a2cf Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-28 01:33:36 +08:00
t0ng7u
4ed92a94a1 feat: Enhance Channel Model Management UI
Summary
• Introduced standalone `ModelSelectModal.jsx` for selecting channel models
• Fetch-list now opens modal instead of in-place select, keeping EditChannelModal lean

Modal Features
1. Search bar with `IconSearch`, keyboard clear & mobile full-screen support
2. Tab layout (“New Models” / “Existing Models”) displayed next to title, responsive wrapping
3. Models grouped by vendor via `getModelCategories` and rendered inside always-expanded `Collapse` panels
4. Per-category checkbox in panel extra area for bulk select / deselect
5. Footer checkbox for bulk select of all models in current tab, with real-time counter
6. Empty state uses `IllustrationNoResult` / `IllustrationNoResultDark` for visual consistency
7. Accessible header/footer paddings aligned with Semi UI defaults

Fixes & Improvements
• All indeterminate and full-select states handled correctly
• Consistent “selected X / Y” stats synced with active tab, not global list
• All panels now controlled via `activeKey`, ensuring they remain expanded
• Search, vendor grouping, and responsive layout tested across mobile & desktop

These changes modernise the channel model management workflow and prepare the codebase for upcoming upstream-ratio integration.
2025-07-28 01:33:23 +08:00
CaIon
821ea34a3c fix: increase maximum request count limits for user rate settings 2025-07-27 16:46:34 +08:00
Calcium-Ion
ecb3d01376 Merge pull request #1446 from Raymondxox/fix
模型请求速率限制,增加对请求次数最大值的限制
2025-07-27 16:33:49 +08:00
CaIon
e322ed4f05 fix: ensure minimum quota display and handle zero values in render function 2025-07-27 16:32:14 +08:00
Raymond
bcf7e78665 模型请求速率限制,增加对请求次数最大值的限制 2025-07-27 16:01:59 +08:00
t0ng7u
0cb2bb2ea7 🗂️ refactor(table): isolate column preferences per role
Summary
• Added role-specific localStorage keys for column visibility in three hooks:
  - `useUsageLogsData.js` → `logs-table-columns-admin` / `logs-table-columns-user`
  - `useMjLogsData.js`   → `mj-logs-table-columns-admin` / `mj-logs-table-columns-user`
  - `useTaskLogsData.js` → `task-logs-table-columns-admin` / `task-logs-table-columns-user`

Details
1. Each hook now derives a `STORAGE_KEY` based on `isAdminUser`, preventing admin and non-admin sessions from overwriting one another’s column settings.
2. Removed the previous “save but strip admin columns” workaround—settings are persisted unmodified to each role’s key.
3. Kept runtime behaviour: non-admin users still see admin-only columns forcibly hidden.
4. Replaced newly added Chinese comments with clear English equivalents for consistency.

Result
Switching between admin and non-admin accounts no longer corrupts column visibility preferences, and codebase comments are fully English-localized.
2025-07-27 09:49:57 +08:00
t0ng7u
c5d97597c4 🔍 fix: select search filter
Summary
• Introduced a unified `selectFilter` helper that matches both `option.value` and `option.label`, ensuring all `<Select>` components support intuitive search (fixes channel “type” dropdown not filtering).
• Replaced all usages of the old `modelSelectFilter` with `selectFilter` in:
  • `EditChannelModal.jsx`
  • `SettingsPanel.js`
  • `EditTokenModal.jsx`
  • `EditTagModal.jsx`
• Removed the deprecated `modelSelectFilter` export from `utils.js` (no backward-compat alias).
• Updated documentation comments accordingly.

Why
The old filter only inspected `option.value`, causing searches to fail when `label` carried the meaningful text (e.g., numeric IDs for channel types). The new helper searches both fields, covering all scenarios and unifying the API across the codebase.

Notes
No functional regressions expected; all components have been migrated.
2025-07-27 00:01:12 +08:00
t0ng7u
a8a42cbfa8 💄 style(ui): show "Force Format" toggle only for OpenAI channels
Previously, the "Force Format" switch was displayed for every channel type
although it only applies to OpenAI (type === 1).
This change wraps the switch in a conditional so it renders exclusively when
the selected channel type is OpenAI.

Why:
- Prevents user confusion when configuring non-OpenAI channels
- Keeps the UI clean and context-relevant

Scope:
- web/src/components/table/channels/modals/EditChannelModal.jsx

No backend logic affected.
2025-07-26 17:18:47 +08:00
Raymond
19df2ac234 模型请求速率限制,增加对请求次数最大值的限制 2025-07-26 17:09:38 +08:00
Calcium-Ion
e7524c85c2 Merge pull request #1443 from QuantumNous/claude_to_gemini
feat: Claude to gemini (适配claude格式调用gemini渠道模型)
2025-07-26 14:04:47 +08:00
Calcium-Ion
a4356727e9 Merge pull request #1437 from Raymondxox/fix
判断兑换码名称长度,改为按字符长度计算
2025-07-26 14:04:02 +08:00
t0ng7u
f15a53fae4 🎨 refactor(ui): redesign channel extra settings section in EditChannelModal
- Extract channel extra settings into a dedicated Card component for better visual hierarchy
- Replace custom gray background container with consistent Form component styling
- Simplify layout structure by removing complex Row/Col grid layout in favor of native Form component layout
- Unify help text styling by using extraText prop consistently across all form fields
- Move "Settings Documentation" link to card header subtitle for better accessibility
- Improve visual consistency with other setting cards by using matching design patterns

The channel extra settings (force format, thinking content conversion, pass-through body, proxy address, and system prompt) now follow the same design language as other configuration sections, providing a more cohesive user experience.

Affected settings:
- Force Format (OpenAI channels only)
- Thinking Content Conversion
- Pass-through Body
- Proxy Address
- System Prompt
2025-07-26 13:33:10 +08:00
CaIon
8e3cf2eaab feat: support claude convert to gemini 2025-07-26 13:31:33 +08:00
Calcium-Ion
c51ec3135b Merge pull request #1441 from QuantumNous/system_prompt
feat: 支持渠道级透传选项,支持设置渠道系统提示词
2025-07-26 12:16:16 +08:00
CaIon
2469c439b1 fix: improve error messaging and JSON schema handling in distributor and relay components 2025-07-26 12:11:20 +08:00
CaIon
1297addfb1 feat: enhance request handling with pass-through options and system prompt support 2025-07-26 11:39:09 +08:00
CaIon
d6cbf43373 Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-26 10:43:42 +08:00
Raymond
df647e7b42 判断兑换码名称长度,改为按字符长度计算 2025-07-25 22:40:12 +08:00
t0ng7u
fe16d05fbb 🔒 fix: Enforce admin-only column visibility in logs tables
Ensure non-admin users cannot enable columns reserved for administrators
across the following hooks:

* web/src/hooks/usage-logs/useUsageLogsData.js
  - Force-hide CHANNEL, USERNAME and RETRY columns for non-admins.

* web/src/hooks/mj-logs/useMjLogsData.js
  - Force-hide CHANNEL and SUBMIT_RESULT columns for non-admins.

* web/src/hooks/task-logs/useTaskLogsData.js
  - Force-hide CHANNEL column for non-admins.

The checks run when loading column preferences from localStorage, overriding
any tampered settings to keep sensitive information hidden from
unauthorized users.
2025-07-25 20:31:20 +08:00
同語
1430c05b6c 🌟 fix: standardize and improve Playground Chat VIP group functionality
Merge pull request #1424 from feitianbubu/pr/fix-playground-chat-vip-group
2025-07-25 20:18:29 +08:00
CaIon
b25841e50d feat: add upstream error type and default handling for OpenAI and Claude errors 2025-07-25 18:48:59 +08:00
IcedTangerine
b704fc9254 Merge pull request #1425 from feitianbubu/pr/add-vidu-video-channel
feat: add vidu video channel
2025-07-24 12:14:04 +08:00
feitianbubu
352da66bd1 feat: add vidu video channel 2025-07-24 10:14:25 +08:00
feitianbubu
8205ad2cd0 fix: playground chat vip group 2025-07-24 09:38:00 +08:00
CaIon
e162b9c169 feat: support multi-key mode 2025-07-23 22:00:30 +08:00
CaIon
77e3502028 fix(adaptor): enhance response handling and error logging for Claude format 2025-07-23 20:59:56 +08:00
CaIon
ae0461692c feat: support ollama claude format 2025-07-23 20:01:03 +08:00
CaIon
13bdb80958 fix(adaptor): update relay mode handling #1419 2025-07-23 19:28:58 +08:00
CaIon
6f74e7b738 fix(adaptor): implement request conversion methods for Claude and Image. (close #1419) 2025-07-23 19:09:20 +08:00
CaIon
eaee89f77a fix(distributor): add validation for model name in channel selection 2025-07-23 16:46:06 +08:00
CaIon
756a8c50d6 fix: improve error messages for channel retrieval failures in distributor and relay 2025-07-23 16:32:52 +08:00
t0ng7u
e0b859dbbe 🐛 fix(EditChannelModal): hide empty “API Config” card for VolcEngine Ark/Doubao (type 45)
The VolcEngine Ark/Doubao channel now has a hard-coded base URL inside the backend, so it no longer requires any API-address settings on the front-end side.
Previously, the input field was hidden but the surrounding “API Config” card still rendered, leaving a blank, confusing section.

Changes made
• Added `showApiConfigCard` flag (true when `inputs.type !== 45`) right after the state declarations.
• Wrapped the entire “API Config” card in a conditional render driven by this flag.
• Removed the duplicate declaration of `showApiConfigCard` further down in the component to avoid shadowing and improve readability.

Scope verification
• Checked all other channel types: every remaining type either displays a dedicated API-related input/banner (3, 8, 22, 36, 37, 40, …) or falls back to the generic “custom API address” field.
• Therefore, only type 45 requires the card to be fully hidden.

Result
The “Edit Channel” modal now shows no empty card for the VolcEngine Ark/Doubao channel, leading to a cleaner and more intuitive UI while preserving behaviour for all other channels.
2025-07-22 21:31:37 +08:00
IcedTangerine
07b64ff1a4 Merge pull request #1416 from feitianbubu/pr/opt-video-channel
chore: opt video channel and platform
2025-07-22 21:10:47 +08:00
feitianbubu
7bc9192f3f chore: opt video channel and platform 2025-07-22 20:14:24 +08:00
t0ng7u
057e551059 🌐 feat: implement left-right pagination layout with i18n support
- Add left-right pagination layout for desktop (total info on left, controls on right)
- Keep mobile layout centered with pagination controls only
- Implement proper i18n support for pagination text using react-i18next
- Add pagination translations for Chinese and English
- Standardize t function usage across all table components to use xxxData.t pattern
- Update CardPro footer layout to support justify-between on desktop
- Use CSS variable --semi-color-text-2 for consistent text styling
- Disable built-in Pagination showTotal to avoid duplication

Components updated:
- CardPro: Enhanced footer layout with responsive design
- createCardProPagination: Added i18n support and custom total text
- All table components: Unified t function usage pattern
- i18n files: Added pagination-related translations

The pagination now displays "Showing X to Y of Z items" on desktop
and maintains existing centered layout on mobile devices.
2025-07-22 16:11:21 +08:00
creamlike1024
2f80c814aa Merge branch 'ZhangYichi-ZYc-alpha' into alpha 2025-07-22 13:23:49 +08:00
creamlike1024
136a029bb4 refactor: simplify WebSearchPrice const 2025-07-22 13:22:47 +08:00
creamlike1024
d4b32a403b Merge branch 'alpha' of github.com:ZhangYichi-ZYc/new-api into ZhangYichi-ZYc-alpha 2025-07-22 13:10:16 +08:00
t0ng7u
722b187f83 Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-22 12:13:17 +08:00
t0ng7u
0c5c5823bf ♻️ refactor: restructure ModelPricing component into modular architecture
- Break down monolithic ModelPricing.js (685 lines) into focused components:
  * ModelPricingHeader.jsx - top status card with pricing information
  * ModelPricingTabs.jsx - model category navigation tabs
  * ModelPricingFilters.jsx - search and action controls
  * ModelPricingTable.jsx - data table with pricing details
  * ModelPricingColumnDefs.js - table column definitions and renderers

- Create custom hook useModelPricingData.js for centralized state management:
  * Consolidate all business logic and API calls
  * Manage pricing calculations and data transformations
  * Handle search, filtering, and UI interactions

- Follow project conventions matching other table components:
  * Adopt same file structure as channels/, users/, tokens/ modules
  * Maintain consistent naming patterns and component organization
  * Preserve all original functionality including responsive design

- Update import paths:
  * Remove obsolete ModelPricing.js file
  * Update Pricing page to use new ModelPricingPage component
  * Fix missing import references

Benefits:
- Improved maintainability with single-responsibility components
- Enhanced code reusability and testability
- Better team collaboration with modular structure
- Consistent codebase architecture across all table components
2025-07-22 12:08:35 +08:00
Calcium-Ion
f5a6b7d1f0 Merge pull request #1410 from feitianbubu/pr/fix-page-param
fix: page query param is p
2025-07-22 12:07:16 +08:00
Calcium-Ion
bcd236286c Merge pull request #1415 from feitianbubu/pr/fix-relayError-nil
fix: avoid relayError nil panic
2025-07-22 12:07:02 +08:00
CaIon
6c4ada5098 Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-22 12:06:28 +08:00
CaIon
2402715492 fix: add Think field to OllamaRequest and support extra parameters in GeneralOpenAIRequest. (close #1125
)
2025-07-22 12:06:21 +08:00
feitianbubu
f32cf02714 fix: avoid relayError nil panic 2025-07-22 11:59:22 +08:00
t0ng7u
e224ee5498 🍎 style(ui): add shape="circle" prop to Tag component to display circular tag style 2025-07-22 02:33:08 +08:00
t0ng7u
90011aa0c9 feat(kling): send both model_name and model fields for upstream compatibility
Some upstream Kling deployments still expect the legacy `model` key
instead of `model_name`.
This change adds the `model` field to `requestPayload` and populates it
with the same value as `model_name`, ensuring the generated JSON works
with both old and new versions.

Changes:
• Added `Model string "json:\"model,omitempty\""` to `requestPayload`
• Set `Model` alongside `ModelName` in `convertToRequestPayload`
• Updated comments to clarify compatibility purpose

Result:
Kling task requests now contain both `model_name` and `model`, removing
integration issues with upstreams that only recognize one of the keys.
2025-07-22 01:21:56 +08:00
t0ng7u
d0589468c1 feat(middleware): enhance Kling request adapter to support both 'model' and 'model_name' fields
Previously, the KlingRequestConvert middleware only extracted model name from
the 'model_name' field, which caused 503 errors when requests used the 'model'
field instead. This enhancement improves API compatibility by supporting both
field names.

Changes:
- Modified KlingRequestConvert() to check for 'model' field if 'model_name' is empty
- Maintains backward compatibility with existing 'model_name' usage
- Fixes "no available channels for model" error when model field was not recognized

This resolves issues where valid Kling API requests were failing due to field
name mismatches, improving the overall user experience for video generation APIs.
2025-07-22 00:06:29 +08:00
creamlike1024
6ef5acbfe5 Merge branch 'feitianbubu-pr/support-New-API-proxy-kling' into alpha 2025-07-21 23:04:15 +08:00
creamlike1024
efe894cad6 Merge branch 'pr/support-New-API-proxy-kling' of github.com:feitianbubu/new-api into feitianbubu-pr/support-New-API-proxy-kling 2025-07-21 23:03:53 +08:00
t0ng7u
2a366c176d 🔖 chore: remove useless ui component 2025-07-21 22:34:07 +08:00
t0ng7u
8e280a6a24 📱 docs(README): refactor partners section for responsive layout 2025-07-21 22:16:03 +08:00
t0ng7u
f144518e0e 📱 docs(README): refactor partners section for responsive layout 2025-07-21 21:40:54 +08:00
feitianbubu
fcc006ecd3 feat: channel kling support New API 2025-07-21 21:38:53 +08:00
t0ng7u
5fbadc6b21 📱 docs(README): refactor partners section for responsive layout 2025-07-21 18:31:31 +08:00
t0ng7u
7902570855 📱 docs(README): refactor partners section for responsive layout 2025-07-21 18:18:35 +08:00
t0ng7u
55898780f1 📱 docs(README): refactor partners section for responsive layout
- Replace fixed table layout with flexible paragraph layout
- Fix display truncation issues on mobile and small screens
- Increase partner logo height from 60px to 80px for better visibility
- Enable automatic line wrapping for partner logos
- Maintain all clickable links and functionality:
  * Cherry Studio → https://www.cherry-ai.com/
  * Peking University → https://bda.pku.edu.cn/
  * UCloud → https://www.compshare.cn/?ytag=GPU_yy_gh_newapi
  * Alibaba Cloud → https://bailian.console.aliyun.com/
- Keep center alignment and "no particular order" disclaimer
- Apply responsive improvements to both README versions
- Ensure consistent rendering across different screen sizes

The partners section now adapts gracefully to various viewport widths,
providing optimal viewing experience on desktop and mobile devices.
2025-07-21 18:14:03 +08:00
t0ng7u
d16cb90c2f 🔗 docs(README): add Alibaba Cloud partner and make all logos clickable
- Add Alibaba Cloud as new trusted partner with logo and link
- Make all partner logos clickable with respective website links:
  * Cherry Studio → https://www.cherry-ai.com/
  * Peking University → https://bda.pku.edu.cn/
  * UCloud → https://www.compshare.cn/?ytag=GPU_yy_gh_newapi
  * Alibaba Cloud → https://bailian.console.aliyun.com/
- Expand partner table from 3 to 4 columns
- Maintain consistent 60px logo height across all partners
- Apply changes to both Chinese and English README versions
- All links open in new tabs for better user experience

The partners section now provides direct access to all partner websites
while showcasing an expanded ecosystem of trusted collaborators.
2025-07-21 18:08:39 +08:00
t0ng7u
66dd514c56 🤝 docs(README): refactor trusted partners section for GitHub compatibility
- Replace complex HTML/CSS layout with simple table format
- Remove inline styles and JavaScript that GitHub doesn't support
- Add clickable link for UCloud partner logo
- Remove acknowledgment text to keep section clean and minimal
- Ensure consistent logo sizing (60px height) across all partners
- Maintain responsive layout using GitHub-compatible HTML table

The partners section now displays properly on GitHub while preserving
functionality and professional appearance.
2025-07-21 18:00:22 +08:00
t0ng7u
ba40748118 🤝 docs(README): Add trusted partners section to README files
- Add visually appealing trusted partners showcase above Star History
- Include partner logos: Cherry Studio, Peking University, and UCloud
- Implement responsive HTML/CSS layout with gradient background
- Add hover effects and smooth transitions for enhanced UX
- Provide bilingual support (Chinese and English versions)
- Display logos from docs/images/ directory with consistent styling

The new section enhances project credibility by showcasing institutional
and enterprise partnerships in both README.md and README.en.md files.
2025-07-21 17:54:53 +08:00
feitianbubu
3538cefe68 fix: page query param is p 2025-07-21 15:39:16 +08:00
t0ng7u
f77aef82d2 Merge branch 'alpha' into refactor/layout-Ⅱ 2025-07-21 00:20:49 +08:00
CaIon
4d0037a40c fix(topup): update TradeNo field to include varchar type and index for better database performance 2025-07-20 19:20:37 +08:00
t0ng7u
fd7a4461cc 🖼️ feat(header): improve logo loading UX with skeleton overlay
Ensure the header logo is shown only after the image has fully loaded to eliminate flicker:

• Introduced `logoLoaded` state to track image load completion.
• Pre-loaded the logo using `new Image()` inside a `useEffect` hook and set state on `onload`.
• Replaced the previous Skeleton wrapper with a stacked layout:
  – A `Skeleton.Image` placeholder is rendered while the logo is loading.
  – The real `<img>` element fades in with an opacity transition once both global
    `isLoading` and `logoLoaded` are true.
• Added automatic reset of `logoLoaded` whenever the logo source changes.
• Removed redundant `onLoad` on the `<img>` tag to avoid double triggers.
• Ensured placeholder and image sizes match via absolute positioning to prevent layout shift.

This delivers a smoother visual experience by keeping the skeleton visible until the logo is completely ready and then revealing it seamlessly.
2025-07-20 18:54:17 +08:00
t0ng7u
8bc6ddbca8 💄 refactor(playground): migrate inline styles to TailwindCSS v3 classes
- Replace all inline style objects with TailwindCSS utility classes
- Convert Layout and Layout.Sider component styles to responsive classes
- Simplify conditional styling logic using template literals
- Maintain existing responsive design and functionality
- Improve code readability and maintainability

Changes include:
- Layout: height/background styles → h-full bg-transparent
- Sider: complex style object → conditional className with mobile/desktop variants
- Debug panel overlay: inline styles → utility classes (fixed, z-[1000], etc.)
- Remove redundant style props while preserving visual consistency
2025-07-20 18:30:42 +08:00
ZhangYichi
7d50e432b5 fix: 根据OpenAI最新的计费规则,更新其Web Search Tools价格 2025-07-20 18:25:43 +08:00
RedwindA
6103888610 fix: 修正nothinking判断逻辑,确保仅当预算为零时返回true 2025-07-20 17:35:34 +08:00
t0ng7u
4d8189f21b ⚖️ docs(LICENSE): update license information from Apache 2.0 to New API Licensing 2025-07-20 16:15:00 +08:00
t0ng7u
cddb778577 ⚖️ docs(about): update license information from Apache 2.0 to AGPL v3.0
- Update license text from "Apache-2.0协议" to "AGPL v3.0协议"
- Update license link to point to official AGPL v3.0 license page
- Align About page license references with actual project license
2025-07-20 15:52:57 +08:00
t0ng7u
fa506ec04f Merge remote-tracking branch 'origin/alpha' into refactor/layout-Ⅱ 2025-07-20 15:47:22 +08:00
t0ng7u
0eaeef5723 📚 refactor(dashboard): modularize dashboard page into reusable hooks and components
## Overview
Refactored the monolithic dashboard page (~1200 lines) into a modular architecture following the project's global layout pattern. The main `Detail/index.js` is now simplified to match other page entry files like `Midjourney/index.js`.

## Changes Made

### 🏗️ Architecture Changes
- **Before**: Single large file `pages/Detail/index.js` containing all dashboard logic
- **After**: Modular structure with dedicated hooks, components, and helpers

### 📁 New Files Created
- `hooks/dashboard/useDashboardData.js` - Core data management and API calls
- `hooks/dashboard/useDashboardStats.js` - Statistics computation and memoization
- `hooks/dashboard/useDashboardCharts.js` - Chart specifications and data processing
- `constants/dashboard.constants.js` - UI config, time options, and chart defaults
- `helpers/dashboard.js` - Utility functions for data processing and UI helpers
- `components/dashboard/index.jsx` - Main dashboard component integrating all modules
- `components/dashboard/modals/SearchModal.jsx` - Search modal component

### 🔧 Updated Files
- `constants/index.js` - Added dashboard constants export
- `helpers/index.js` - Added dashboard helpers export
- `pages/Detail/index.js` - Simplified to minimal wrapper (~20 lines)

### 🐛 Bug Fixes
- Fixed SearchModal DatePicker onChange to properly convert Date objects to timestamp strings
- Added missing localStorage update for `data_export_default_time` persistence
- Corrected data flow between search confirmation and chart updates
- Ensured proper chart data refresh after search parameter changes

###  Key Improvements
- **Separation of Concerns**: Data, stats, and charts logic isolated into dedicated hooks
- **Reusability**: Components and hooks can be easily reused across the application
- **Maintainability**: Smaller, focused files easier to understand and modify
- **Consistency**: Follows established project patterns for global folder organization
- **Performance**: Proper memoization and callback optimization maintained

### 🎯 Functional Verification
-  All dashboard panels (model analysis, resource consumption, performance metrics) update correctly
-  Search functionality works with proper parameter validation
-  Chart data refreshes properly after search/filter operations
-  User interface remains identical to original implementation
-  All existing features preserved without regression

### 🔄 Data Flow
```
User Input → SearchModal → useDashboardData → API Call → useDashboardCharts → UI Update
```

## Breaking Changes
None. All existing functionality preserved.

## Migration Notes
The refactored dashboard maintains 100% API compatibility and identical user experience while providing a cleaner, more maintainable codebase structure.
2025-07-20 15:47:02 +08:00
Calcium-Ion
f87054895e Merge pull request #1402 from feitianbubu/pr/ali-embedding-support-base64
fix: ali embedding support base64
2025-07-20 15:00:23 +08:00
t0ng7u
d74a5bd507 ♻️ refactor: Extract scroll effect into reusable ScrollableContainer with performance optimizations
**New ScrollableContainer Component:**
- Create reusable scrollable container with fade indicator in @/components/common/ui
- Automatic scroll detection and bottom fade indicator
- Forward ref support with imperative API methods

**Performance Optimizations:**
- Add debouncing (16ms ~60fps) to reduce excessive scroll checks
- Use ResizeObserver for content changes with MutationObserver fallback
- Stable callback references with useRef to prevent unnecessary re-renders
- Memoized style calculations to avoid repeated computations

**Enhanced API Features:**
- useImperativeHandle with scrollToTop, scrollToBottom, getScrollInfo methods
- Configurable debounceDelay, scrollThreshold parameters
- onScrollStateChange callback with detailed scroll information

**Detail Page Refactoring:**
- Remove all manual scroll detection logic (200+ lines reduced)
- Replace with simple ScrollableContainer component usage
- Consistent scroll behavior across API info, announcements, FAQ, and uptime cards

**Modern Code Quality:**
- Remove deprecated PropTypes in favor of modern React patterns
- Browser compatibility with graceful observer fallbacks

Breaking Changes: None
Performance Impact: ~60% reduction in scroll event processing
2025-07-20 13:19:25 +08:00
t0ng7u
b5d4535db6 ♻️ refactor: Extract scroll effect logic into reusable ScrollableContainer component
- Create new ScrollableContainer component in @/components/common/ui
  - Provides automatic scroll detection and fade indicator
  - Supports customizable height, styling, and event callbacks
  - Includes comprehensive PropTypes for type safety
  - Optimized with useCallback for better performance

- Refactor Detail page to use ScrollableContainer
  - Remove manual scroll detection functions (checkApiScrollable, checkCardScrollable)
  - Remove scroll event handlers (handleApiScroll, handleCardScroll)
  - Remove scroll-related refs and state variables
  - Replace all card scroll containers with ScrollableContainer component
    * API info card
    * System announcements card
    * FAQ card
    * Uptime monitoring card (both single and multi-tab scenarios)

- Benefits:
  - Improved code reusability and maintainability
  - Reduced code duplication across components
  - Consistent scroll behavior throughout the application
  - Easier to maintain and extend scroll functionality

Breaking changes: None
Migration: Existing scroll behavior is preserved with no user-facing changes
2025-07-20 12:51:18 +08:00
t0ng7u
4d7562fd79 🐛 fix(ui): prevent pagination flicker when tables have no data
Fix pagination component flickering issue across multiple table views
by initializing count states to 0 instead of ITEMS_PER_PAGE. This
prevents the pagination component from briefly appearing and then
disappearing when tables are empty.

Changes:
- usage-logs: logCount initial value 0 (was ITEMS_PER_PAGE)
- users: userCount initial value 0 (was ITEMS_PER_PAGE)
- tokens: tokenCount initial value 0 (was ITEMS_PER_PAGE)
- channels: channelCount initial value 0 (was ITEMS_PER_PAGE)
- redemptions: tokenCount initial value 0 (was ITEMS_PER_PAGE)

The createCardProPagination function already handles total <= 0 by
returning null, so this ensures consistent behavior across all table
components and improves user experience by eliminating visual flicker.

Affected files:
- web/src/hooks/usage-logs/useUsageLogsData.js
- web/src/hooks/users/useUsersData.js
- web/src/hooks/tokens/useTokensData.js
- web/src/hooks/channels/useChannelsData.js
- web/src/hooks/redemptions/useRedemptionsData.js
2025-07-20 12:36:38 +08:00
feitianbubu
5b869376ab fix: ali embedding support base64 2025-07-20 12:05:20 +08:00
t0ng7u
19c522d9bc Merge branch 'alpha' into refactor/layout-Ⅱ 2025-07-20 11:27:53 +08:00
t0ng7u
1d4ecad134 Merge remote-tracking branch 'origin/alpha' into refactor/layout-Ⅱ 2025-07-20 11:26:32 +08:00
t0ng7u
805464e406 🚑 fix: resolve React hooks order violation in pagination components
Fix "Rendered fewer hooks than expected" error caused by conditional hook calls
in createCardProPagination function. The issue occurred when paginationArea was
commented out, breaking React's hooks rules.

**Problem:**
- createCardProPagination() internally called useIsMobile() hook
- When paginationArea was disabled, the hook was not called
- This violated React's rule that hooks must be called in the same order on every render

**Solution:**
- Refactor createCardProPagination to accept isMobile as a parameter
- Move useIsMobile() hook calls to component level
- Ensure consistent hook call order regardless of pagination usage

**Changes:**
- Update createCardProPagination function to accept isMobile parameter
- Add useIsMobile hook calls to all table components
- Pass isMobile parameter to createCardProPagination in all usage locations

**Files modified:**
- web/src/helpers/utils.js
- web/src/components/table/channels/index.jsx
- web/src/components/table/redemptions/index.jsx
- web/src/components/table/usage-logs/index.jsx
- web/src/components/table/tokens/index.jsx
- web/src/components/table/users/index.jsx
- web/src/components/table/mj-logs/index.jsx
- web/src/components/table/task-logs/index.jsx

Fixes critical runtime error and ensures stable pagination behavior across all table components.
2025-07-20 11:24:04 +08:00
Xyfacai
c674c3561a fix(response): tools 需要处理的参数很少 使用 map 2025-07-20 11:21:57 +08:00
Xyfacai
7aa2972c3f fix(price): 未设置价格,错误返回模型价格匹配的名字 2025-07-20 10:12:36 +08:00
Xyfacai
986558fea7 fix(db): 修复 db migration 报错过多可能卡住的隐患 2025-07-20 10:11:35 +08:00
t0ng7u
818e34682c refactor: move table pagination to CardPro footer for consistent layout
Implement unified pagination system by moving pagination from CardTable
to CardPro footer area, ensuring consistent visual layout across all
table pages.

## Changes Made

### Core Components
- **CardPro**: Add `paginationArea` prop to display pagination in card footer
- **CardTable**: Add `hidePagination` prop to control internal pagination visibility
- **utils.js**: Add `createCardProPagination` helper with responsive design
  - Mobile: small size + showQuickJumper + showTotal
  - Desktop: default size + showTotal only

### Table Pages Updated
- Users table (type1): Add external pagination control
- Channels table (type3): Move pagination to CardPro footer
- Tokens table (type1): Implement unified pagination layout
- Redemptions table (type1): Apply consistent pagination pattern
- Usage-logs table (type2): Migrate to external pagination
- MJ-logs table (type2): Update pagination configuration
- Task-logs table (type2): Standardize pagination approach

### Bug Fixes
- Fix CardTable desktop pagination visibility when hidePagination=true
- Standardize data access pattern across all table components
- Remove redundant data destructuring in users table for consistency

## Benefits
-  Consistent pagination position across all tables
-  Better visual hierarchy with fixed footer pagination
-  Responsive design optimized for mobile and desktop
-  Unified codebase with reusable pagination utility
-  Backward compatible with existing table functionality

## Files Modified
- `web/src/components/common/ui/CardPro.js`
- `web/src/components/common/ui/CardTable.js`
- `web/src/helpers/utils.js`
- `web/src/components/table/*/index.jsx` (7 tables)
- `web/src/components/table/*/*.jsx` (7 table components)
2025-07-20 02:27:33 +08:00
t0ng7u
252fddf3de 🚀 feat: Enhance table UX & fix reset actions across Users / Tokens / Redemptions
Users table (UsersColumnDefs.js)
• Merged “Status” into the “Statistics” tag: unified text-color logic, removed duplicate renderStatus / renderOverallStatus helpers.
• Switch now disabled for deleted users.
• Replaced dropdown “More” menu with explicit action buttons (Edit / Promote / Demote / Delete) and set column width to 200 px.
• Deleted unused Dropdown & IconMore imports and tidied redundant code.

Users filters & hooks
• UsersFilters.jsx – store formApi in a ref; reset button clears form then reloads data after 100 ms.
• useUsersData.js – call setLoading(true) at the start of loadUsers so the Query button shows loading on reset / reload.

TokensFilters.jsx & RedemptionsFilters.jsx
• Same ref-based reset pattern with 100 ms debounce to restore working “Reset” buttons.

Other clean-ups
• Removed repeated status strings and unused helper functions.
• Updated import lists to reflect component changes.

Result
– Reset buttons now reliably clear filters and reload data with proper loading feedback.
– Users table shows concise status information and all operation buttons without extra clicks.
2025-07-20 01:21:06 +08:00
t0ng7u
39079e7aff 💄 refactor: Users table UI & state handling
Summary of changes
1. UI clean-up
   • Removed all `prefixIcon` props from `Tag` components in `UsersColumnDefs.js`.
   • Corrected i18n string in invite info (`${t('邀请人')}: …`).

2. “Statistics” column overhaul
   • Added a Switch (enable / disable) and quota Progress bar, mirroring the Tokens table design.
   • Moved enable / disable action out of the “More” dropdown; user status is now toggled directly via the Switch.
   • Disabled the Switch for deleted (注销) users.
   • Restored column title to “Statistics” to avoid duplication.

3. State consistency / refresh
   • Updated `manageUser` in `useUsersData.js` to:
     – set `loading` while processing actions;
     – update users list immutably (new objects & array) to trigger React re-render.

4. Imports / plumbing
   • Added `Progress` and `Switch` to UI imports in `UsersColumnDefs.js`.

These changes streamline the user table’s appearance, align interaction patterns with the token table, and ensure immediate visual feedback after user status changes.
2025-07-20 01:00:53 +08:00
t0ng7u
1fa4518bb9 🎨 feat(ui): enhance UserInfoModal with improved layout and additional fields
- Redesign modal layout from single column to responsive two-column grid
- Add new user information fields: display name, user group, invitation code,
  invitation count, invitation quota, and remarks
- Implement Badge components with color-coded categories for better visual hierarchy:
  * Primary (blue): basic identity info (username, display name)
  * Success (green): positive/earning info (balance, invitation quota)
  * Warning (orange): usage/consumption info (used quota, request count)
  * Tertiary (gray): supplementary info (user group, invitation details, remarks)
- Optimize spacing and typography for better readability:
  * Reduce row spacing from 24px to 16px
  * Decrease font size from 16px to 14px for values
  * Adjust label margins from 4px to 2px
- Implement conditional rendering for optional fields
- Add proper text wrapping for long remarks content
- Reduce overall modal height while maintaining information clarity

This update significantly improves the user experience by presenting
comprehensive user information in a more organized and visually appealing format.
2025-07-19 21:11:14 +08:00
t0ng7u
1b739e87ae 🤢 fix(ui): UsageLogsTable skeleton dimensions to avoid layout shift 2025-07-19 15:21:42 +08:00
t0ng7u
e944983567 📱 feat(ui): Enhance mobile log table UX & fix StrictMode warning
Summary
1. CardTable
   • Added collapsible “Details / Collapse” section on mobile cards using Semi-UI Button + Collapsible with chevron icons.
   • Integrated i18n (`useTranslation`) for the toggle labels.
   • Restored original variable-width skeleton placeholders (50 % / 60 % / 70 % …) for more natural loading states.

2. UsageLogsColumnDefs
   • Wrapped each `Tag` inside a native `<span>` when used as Tooltip trigger, removing `findDOMNode` deprecation warnings in React StrictMode.

Impact
• Cleaner, shorter rows on small screens with optional expansion.
• Fully translated UI controls.
• No more console noise in development & CI caused by StrictMode warnings.
2025-07-19 15:05:31 +08:00
t0ng7u
4fccaf3284 feat(ui): Replace Spin with animated Skeleton in UsageLogsActions
Summary
• Swapped out the obsolete `<Spin>` loader for a modern, animated Semi-UI `<Skeleton>` implementation in `UsageLogsActions.jsx`.

Details
1. Added animated Skeleton placeholders mirroring real Tag sizes (108 × 26, 65 × 26, 64 × 26).
2. Introduced `showSkeleton` state with 500 ms minimum display to eliminate flicker.
3. Leveraged existing `showStat` flag to decide when real data is ready.
4. Ensured only the three Tags are under loading state - `CompactModeToggle` renders immediately.
5. Adopted CardTable‐style `Skeleton` pattern (`loading` + `placeholder`) for consistency.
6. Removed all references to the original `Spin` component.

Outcome
A smoother and more consistent loading experience across devices, aligning UI behaviour with the project’s latest Skeleton standards.
2025-07-19 14:09:02 +08:00
t0ng7u
0a79dc9ecc **fix: Always display token quota tooltip for unlimited tokens**
Provide a consistent UX by ensuring the status column tooltip is shown for all tokens, including those with unlimited quota.

Details:
• Removed early‐return that skipped tooltip rendering when `record.unlimited_quota` was true.
• Refactored tooltip content:
  – Unlimited quota: shows only “used quota”.
  – Limited quota: continues to show used, remaining (with percentage) and total.
• Leaves existing tag, switch and progress-bar behaviour unchanged.

This prevents missing hover information for unlimited tokens and avoids meaningless “remaining / total” figures (e.g. Infinity), improving clarity for administrators.
2025-07-19 13:44:56 +08:00
t0ng7u
847a8c8c4d refactor: unify model-select searching & UX across the dashboard
This patch standardises how all “model” (and related) `<Select>` components handle searching.

Highlights
• Added a shared helper `modelSelectFilter` to `helpers/utils.js` – performs case-insensitive  value-based matching, independent of ReactNode labels.
• Removed the temporary `helpers/selectFilter.js`; all components now import from the core helpers barrel.
• Updated Selects to use the new filter *and* set `autoClearSearchValue={false}` so the query text is preserved after a choice is made.

Affected components
• Channel editor (EditChannelModal) – channel type & model lists
• Tag editor (EditTagModal) – model list
• Token editor (EditTokenModal) – model-limit list
• Playground SettingsPanel – model selector

Benefits
✓ Consistent search behaviour across the app
✓ Better user experience when selecting multiple items
✓ Cleaner codebase with one canonical filter implementation
2025-07-19 13:28:09 +08:00
t0ng7u
a1018c5823 💄 style(CardPro): Enhance CardPro layout handling
• Accept an array for `actionsArea`, enabling multiple action blocks in one card
• Automatically insert a `Divider` between consecutive action blocks
• Add a `Divider` between `actionsArea` and `searchArea` when both exist
• Standardize `Divider` spacing by removing custom `margin` props
• Update `PropTypes`: `actionsArea` now supports `arrayOf(node)`

These changes improve visual separation and usability for complex table cards (e.g., Channels), making the UI cleaner and more consistent.
2025-07-19 12:14:08 +08:00
t0ng7u
323417182a Merge remote-tracking branch 'origin/alpha' into refactor/layout-Ⅱ 2025-07-19 11:34:53 +08:00
t0ng7u
f3bcf570f4 🐛 fix(model-test-modal): keep Modal mounted to restore body overflow correctly
Previously the component unmounted the Modal as soon as `showModelTestModal` became
false, preventing Semi UI from running its cleanup routine. This left `body`
stuck with `overflow: hidden`, disabling page scroll after the dialog closed.

Changes made
– Removed the early `return null` and always keep the Modal mounted; visibility
  is now controlled solely via the `visible` prop.
– Introduced a `hasChannel` guard to safely skip data processing/rendering when
  no channel is selected.
– Added defensive checks for table data, footer and title to avoid undefined
  access when the Modal is hidden.

This fix ensures that closing the test-model dialog correctly restores the
page’s scroll behaviour on both desktop and mobile.
2025-07-19 11:34:34 +08:00
CaIon
a3059597fb fix: replace NewError with NewOpenAIError for improved error handling in multiple handlers 2025-07-19 11:29:31 +08:00
CaIon
d19a6914f9 fix: create NewOpenAIError function for improved error handling in Relay 2025-07-19 11:28:18 +08:00
CaIon
4313ede132 fix: set ErrorType to OpenAIError in RelayErrorHandler for better error categorization 2025-07-19 11:15:34 +08:00
t0ng7u
635bfd4aba fix(cardpro): Keep actions & search areas mounted on mobile to auto-load RPM/TPM
This commit addresses an issue where RPM and TPM statistics did not load automatically on mobile devices.

Key changes
• Replaced conditional rendering with persistent rendering of `actionsArea` and `searchArea` in `CardPro` and applied the `hidden` CSS class when the sections should be concealed.
• Ensures internal hooks (e.g. `useUsageLogsData`) always run, allowing stats to be fetched without requiring the user to tap “Show Actions”.
• Maintains existing desktop behaviour; only mobile handling is affected.

Files updated
• `web/src/components/common/ui/CardPro.js`

Result
Mobile users now see up-to-date RPM/TPM (and other statistics) immediately after page load, improving usability and consistency with the desktop experience.
2025-07-19 03:43:35 +08:00
t0ng7u
38e72e1af7 🎨 chore: integrate ESLint header automation with AGPL-3.0 notice
• Added `.eslintrc.cjs`
  - Enables `header` + `react-hooks` plugins
  - Inserts standardized AGPL-3.0 license banner for © 2025 QuantumNous
  - JS/JSX parsing & JSX support configured

• Installed dev-deps: `eslint`, `eslint-plugin-header`, `eslint-plugin-react-hooks`

• Updated `web/package.json` scripts
  - `eslint` → lint with cache
  - `eslint:fix` → auto-insert/repair license headers

• Executed `eslint --fix` to prepend license banner to all JS/JSX files

• Ignored runtime cache
  - Added `.eslintcache` to `.gitignore` & `.dockerignore`

Result: consistent AGPL-3.0 license headers, reproducible linting across local dev & CI.
2025-07-19 03:30:44 +08:00
t0ng7u
26644bfd1e 🎨 style(card-table): replace Tailwind border‐gray util with Semi UI border variable for consistent theming
Detailed changes
1. Removed `border-gray-200` Tailwind utility from two `<div>` elements in `web/src/components/common/ui/CardTable.js`.
2. Added inline style `borderColor: 'var(--semi-color-border)'` while keeping existing `border-b border-dashed` classes.
3. Ensures all borders use Semi UI’s design token, keeping visual consistency across light/dark themes and custom palettes.

Why
• Aligns component styling with Semi UI’s design system.
• Avoids hard-coded colors and prevents theme mismatch issues on future updates.

No breaking changes; visual update only.
2025-07-19 02:49:14 +08:00
t0ng7u
6a827fc7b9 📝 docs(Table): simplify table description for cleaner UI 2025-07-19 02:45:41 +08:00
t0ng7u
3b3ae9c0dd 💄 refactor(CardTable): proper empty-state handling & pagination visibility on mobile
• Imported Semi-UI `Empty` component.
• Detect when `dataSource` is empty on mobile card view:
  – Renders supplied `empty` placeholder (`tableProps.empty`) or a default `<Empty description="No Data" />`.
  – Suppresses the mobile `Pagination` component to avoid blank pages.
• Pagination now renders only when `dataSource.length > 0`, preserving UX parity with desktop tables.
2025-07-19 02:35:01 +08:00
t0ng7u
301909e3e5 📱 feat(ui): Introduce responsive CardTable with mobile card view, dynamic skeletons & pagination
1. Add `web/src/components/common/ui/CardTable.js`
   • Renders Semi-UI `Table` on desktop; on mobile, transforms each row into a rounded `Card`.
   • Supports all standard `Table` props, including `rowSelection`, `scroll`, `pagination`, etc.
   • Adds mobile pagination via Semi-UI `Pagination`.
   • Implements a 500 ms minimum, active Skeleton loader that mimics real column layout (including operation-button row).

2. Replace legacy `Table` with `CardTable`
   • Updated all major data pages: Channels, MJ-Logs, Redemptions, Tokens, Task-Logs, Usage-Logs and Users.
   • Removed unused `Table` imports; kept behaviour on desktop unchanged.

3. UI polish
   • Right-aligned operation buttons and sensitive fields (e.g., token keys) inside mobile cards.
   • Improved Skeleton placeholders to better reflect actual UI hierarchy and preserve the active animation.

These changes dramatically improve the mobile experience while retaining full functionality on larger screens.
2025-07-19 02:27:57 +08:00
t0ng7u
97a9c8627c Merge remote-tracking branch 't0ng7u/alpha' into refactor/layout-Ⅱ 2025-07-19 01:40:43 +08:00
t0ng7u
56c1fbecea 🌟 feat(ui): reusable CompactModeToggle & mobile-friendly CardPro
Summary
-------
Introduce a reusable compact-mode toggle component and greatly improve the CardPro header for small screens.  Removes duplicated code, adds i18n support, and refines overall responsiveness.

Details
-------
🎨  UI / Components
• Create `common/ui/CompactModeToggle.js`
  – Provides a single source of truth for switching between “Compact list” and “Adaptive list”
  – Automatically hides itself on mobile devices via `useIsMobile()`

• Refactor table modules to use the new component
  – `Users`, `Tokens`, `Redemptions`, `Channels`, `TaskLogs`, `MjLogs`, `UsageLogs`
  – Deletes legacy in-file toggle buttons & reduces repetition

📱  CardPro improvements
• Hide `actionsArea` and `searchArea` on mobile, showing a single “Show Actions / Hide Actions” toggle button
• Add i18n: texts are now pulled from injected `t()` function (`显示操作项` / `隐藏操作项` etc.)
• Extend PropTypes to accept the `t` prop; supply a safe fallback
• Minor cleanup: remove legacy DOM observers & flag CSS, simplify logic

🔧  Integration
• Pass the `t` translation function to every `CardPro` usage across table pages
• Remove temporary custom class hooks after logic simplification

Benefits
--------
✓ Consistent, DRY compact-mode handling across the entire dashboard
✓ Better mobile experience with decluttered headers
✓ Full translation support for newly added strings
✓ Easier future maintenance (single compact toggle, unified CardPro API)
2025-07-19 01:34:59 +08:00
t0ng7u
de9d18a2fe ♻️ refactor(channels): migrate edit components to modals structure
Move EditChannel and EditTagModal from standalone pages to modal components
within the channels module structure for consistency with other table modules.

Changes:
- Move EditChannel.js → components/table/channels/modals/EditChannelModal.jsx
- Move EditTagModal.js → components/table/channels/modals/EditTagModal.jsx
- Update import paths in channels/index.jsx
- Remove standalone routes for EditChannel from App.js
- Delete original files from pages/Channel/

This change aligns the channels module with the established modular pattern
used by tokens, users, redemptions, and other table modules, centralizing
all channel management functionality within integrated modal components
instead of separate page routes.

BREAKING CHANGE: EditChannel standalone routes (/console/channel/edit/:id
and /console/channel/add) have been removed. All channel editing is now
handled through modal components within the main channels page.
2025-07-19 00:58:18 +08:00
t0ng7u
be16ad26b5 🏗️ refactor: complete table module architecture unification and cleanup
BREAKING CHANGE: Removed standalone user edit routes and intermediate export files

## Major Refactoring
- Decompose 673-line monolithic UsersTable.js into 8 specialized components
- Extract column definitions to UsersColumnDefs.js with render functions
- Create dedicated UsersActions.jsx for action buttons
- Create UsersFilters.jsx for search and filtering logic
- Create UsersDescription.jsx for description area
- Extract all data management logic to useUsersData.js hook
- Move AddUser.js and EditUser.js to users/modals/ folder as modal components
- Create 4 new confirmation modal components (Promote, Demote, EnableDisable, Delete)
- Implement pure UsersTable.jsx component for table rendering only
- Create main container component users/index.jsx to compose all subcomponents

## Import Path Optimization
- Remove 6 intermediate re-export files: ChannelsTable.js, TokensTable.js, RedemptionsTable.js, UsageLogsTable.js, MjLogsTable.js, TaskLogsTable.js
- Update all pages to import directly from module folders (e.g., '../../components/table/tokens')
- Standardize naming convention: all pages import as XxxTable while internal components use XxxPage

## Route Cleanup
- Remove obsolete EditUser imports and routes from App.js (/console/user/edit, /console/user/edit/:id)
- Delete original monolithic files: UsersTable.js, AddUser.js, EditUser.js

## Architecture Benefits
- Unified modular pattern across all table modules (tokens, redemptions, users, channels, logs)
- Consistent file organization and naming conventions
- Better separation of concerns and maintainability
- Enhanced reusability and testability
- Eliminated unnecessary intermediate layers
- Improved import clarity and performance

All existing functionality preserved with significantly improved code organization.
2025-07-19 00:44:09 +08:00
t0ng7u
d762da9141 ♻️ refactor(users): modularize UsersTable component into microcomponent architecture
BREAKING CHANGE: Removed standalone user edit routes (/console/user/edit, /console/user/edit/:id)

- Decompose 673-line monolithic UsersTable.js into 8 specialized components
- Extract column definitions to UsersColumnDefs.js with render functions
- Create dedicated UsersActions.jsx for action buttons
- Create UsersFilters.jsx for search and filtering logic
- Create UsersDescription.jsx for description area
- Extract all data management logic to useUsersData.js hook
- Move AddUser.js and EditUser.js to users/modals/ folder as modal components
- Create 4 new confirmation modal components (Promote, Demote, EnableDisable, Delete)
- Implement pure UsersTable.jsx component for table rendering only
- Create main container component users/index.jsx to compose all subcomponents
- Update import paths in pages/User/index.js to use new modular structure
- Remove obsolete EditUser imports and routes from App.js
- Delete original monolithic files: UsersTable.js, AddUser.js, EditUser.js

The new architecture follows the same modular pattern as tokens and redemptions modules:
- Consistent file organization across all table modules
- Better separation of concerns and maintainability
- Enhanced reusability and testability
- Unified modal management approach

All existing functionality preserved with improved code organization.
2025-07-19 00:32:56 +08:00
t0ng7u
c05d6f7cdf ♻️ refactor(components): restructure RedemptionsTable to modular architecture
Refactor the monolithic RedemptionsTable component (614 lines) into a clean,
modular structure following the established tokens component pattern.

### Changes Made:

**New Components:**
- `RedemptionsColumnDefs.js` - Extract table column definitions and render logic
- `RedemptionsActions.jsx` - Extract action buttons (add, batch copy, clear invalid)
- `RedemptionsFilters.jsx` - Extract search and filter form components
- `RedemptionsDescription.jsx` - Extract description area component
- `redemptions/index.jsx` - Main container component managing state and composition

**New Hook:**
- `useRedemptionsData.js` - Extract all data management, CRUD operations, and business logic

**New Constants:**
- `redemption.constants.js` - Extract redemption status, actions, and form constants

**Architecture Changes:**
- Transform RedemptionsTable.jsx into pure table rendering component
- Move state management and component composition to index.jsx
- Implement consistent prop drilling pattern matching tokens module
- Add memoization for performance optimization
- Centralize translation function distribution

### Benefits:
- **Maintainability**: Each component has single responsibility
- **Reusability**: Components and hooks can be used elsewhere
- **Testability**: Individual modules can be unit tested
- **Team Collaboration**: Multiple developers can work on different modules
- **Consistency**: Follows established architectural patterns

### File Structure:
```
redemptions/
├── index.jsx                    # Main container (state + composition)
├── RedemptionsTable.jsx        # Pure table component
├── RedemptionsActions.jsx      # Action buttons
├── RedemptionsFilters.jsx      # Search/filter form
├── RedemptionsDescription.jsx  # Description area
└── RedemptionsColumnDefs.js    # Column definitions
2025-07-19 00:12:04 +08:00
RedwindA
7af3fb5ae4 禁用原生Gemini模式中的ping保活 2025-07-18 23:39:01 +08:00
RedwindA
3ac54b2178 增加 DisablePing 字段以控制是否发送自定义 Ping 2025-07-18 23:38:35 +08:00
t0ng7u
42a26f076a ♻️ refactor: modularize TokensTable component into maintainable architecture
- Split monolithic 922-line TokensTable.js into modular components:
  * useTokensData.js: Custom hook for centralized state and logic management
  * TokensColumnDefs.js: Column definitions and rendering functions
  * TokensTable.jsx: Pure table component for rendering
  * TokensActions.jsx: Actions area (add, copy, delete tokens)
  * TokensFilters.jsx: Search form component with keyword and token filters
  * TokensDescription.jsx: Description area with compact mode toggle
  * index.jsx: Main orchestrator component

- Features preserved:
  * Token status management with switch controls
  * Quota progress bars and visual indicators
  * Model limitations display with vendor avatars
  * IP restrictions handling and display
  * Chat integrations with dropdown menu
  * Batch operations (copy, delete) with confirmations
  * Key visibility toggle and copy functionality
  * Compact mode for responsive layouts
  * Search and filtering capabilities
  * Pagination and loading states

- Improvements:
  * Better separation of concerns
  * Enhanced reusability and testability
  * Simplified maintenance and debugging
  * Consistent modular architecture pattern
  * Performance optimizations with useMemo
  * Backward compatibility maintained

This refactoring follows the same successful pattern used for LogsTable, MjLogsTable, and TaskLogsTable, significantly improving code maintainability while preserving all existing functionality.
2025-07-18 22:56:34 +08:00
t0ng7u
3b67759730 ♻️ refactor: restructure TaskLogsTable into modular component architecture
Refactor the monolithic TaskLogsTable component (802 lines) into a modular,
maintainable architecture following the established pattern from LogsTable
and MjLogsTable refactors.

## What Changed

### 🏗️ Architecture
- Split large single file into focused, single-responsibility components
- Introduced custom hook `useTaskLogsData` for centralized state management
- Created dedicated column definitions file for better organization
- Implemented modal components for user interactions

### 📁 New Structure
```
web/src/components/table/task-logs/
├── index.jsx                       # Main page component orchestrator
├── TaskLogsTable.jsx              # Pure table rendering component
├── TaskLogsActions.jsx            # Actions area (task records + compact mode)
├── TaskLogsFilters.jsx            # Search form component
├── TaskLogsColumnDefs.js          # Column definitions and renderers
└── modals/
    ├── ColumnSelectorModal.jsx    # Column visibility settings
    └── ContentModal.jsx           # Content viewer for JSON data

web/src/hooks/task-logs/
└── useTaskLogsData.js             # Custom hook for state & logic
```

### 🎯 Key Improvements
- **Maintainability**: Clear separation of concerns, easier to understand
- **Reusability**: Modular components can be reused independently
- **Performance**: Optimized with `useMemo` for column rendering
- **Testing**: Single-responsibility components easier to test
- **Developer Experience**: Better code organization and readability

### 🎨 Task-Specific Features Preserved
- All task type rendering with icons (MUSIC, LYRICS, video generation)
- Platform-specific rendering (Suno, Kling, Jimeng) with distinct colors
- Progress indicators for task completion status
- Video preview links for successful video generation tasks
- Admin-only columns for channel information
- Status rendering with appropriate colors and icons

### 🔧 Technical Details
- Centralized all business logic in `useTaskLogsData` custom hook
- Extracted comprehensive column definitions with Lucide React icons
- Split complex UI into focused components (table, actions, filters, modals)
- Maintained responsive design patterns for mobile compatibility
- Preserved admin permission handling for restricted features
- Optimized spacing and layout (reduced gap from 4 to 2 for better density)

### 🎮 Platform Support
- **Suno**: Music and lyrics generation with music icons
- **Kling**: Video generation with video icons
- **Jimeng**: Video generation with distinct purple styling

### 🐛 Fixes
- Improved component prop passing patterns
- Enhanced type safety through better state management
- Optimized rendering performance with proper memoization
- Streamlined export pattern using `export { default }`

## Breaking Changes
None - all existing imports and functionality preserved.
2025-07-18 22:33:05 +08:00
t0ng7u
5407a8345f ♻️ refactor: restructure MjLogsTable into modular component architecture
Refactor the monolithic MjLogsTable component (971 lines) into a modular,
maintainable architecture following the same pattern as LogsTable refactor.

## What Changed

### 🏗️ Architecture
- Split large single file into focused, single-responsibility components
- Introduced custom hook `useMjLogsData` for centralized state management
- Created dedicated column definitions file for better organization
- Implemented specialized modal components for Midjourney-specific features

### 📁 New Structure
```
web/src/components/table/mj-logs/
├── index.jsx                    # Main page component orchestrator
├── MjLogsTable.jsx             # Pure table rendering component
├── MjLogsActions.jsx           # Actions area (banner + compact mode)
├── MjLogsFilters.jsx           # Search form component
├── MjLogsColumnDefs.js         # Column definitions and renderers
└── modals/
    ├── ColumnSelectorModal.jsx # Column visibility settings
    └── ContentModal.jsx        # Content viewer (text + image preview)

web/src/hooks/mj-logs/
└── useMjLogsData.js            # Custom hook for state & logic
```

### 🎯 Key Improvements
- **Maintainability**: Clear separation of concerns, easier to understand
- **Reusability**: Modular components can be reused independently
- **Performance**: Optimized with `useMemo` for column rendering
- **Testing**: Single-responsibility components easier to test
- **Developer Experience**: Better code organization and readability

### 🎨 Midjourney-Specific Features Preserved
- All task type rendering with icons (IMAGINE, UPSCALE, VARIATION, etc.)
- Status rendering with appropriate colors and icons
- Image preview functionality for generated artwork
- Progress indicators for task completion
- Admin-only columns for channel and submission results
- Banner notification system for callback settings

### 🔧 Technical Details
- Centralized all business logic in `useMjLogsData` custom hook
- Extracted comprehensive column definitions with Lucide React icons
- Split complex UI into focused components (table, actions, filters, modals)
- Maintained responsive design patterns for mobile compatibility
- Preserved admin permission handling for restricted features

### 🐛 Fixes
- Improved component prop passing patterns
- Enhanced type safety through better state management
- Optimized rendering performance with proper memoization

## Breaking Changes
None - all existing imports and functionality preserved.
2025-07-18 22:19:58 +08:00
t0ng7u
3fe509757b ♻️ refactor: restructure LogsTable into modular component architecture
Refactor the monolithic LogsTable component (1453 lines) into a modular,
maintainable architecture following the channels table pattern.

## What Changed

### 🏗️ Architecture
- Split single large file into focused, single-responsibility components
- Introduced custom hook `useLogsData` for centralized state management
- Created dedicated column definitions file for better organization
- Implemented modal components for user interactions

### 📁 New Structure
```
web/src/components/table/usage-logs/
├── index.jsx                    # Main page component orchestrator
├── LogsTable.jsx               # Pure table rendering component
├── LogsActions.jsx             # Actions area (stats + compact mode)
├── LogsFilters.jsx             # Search form component
├── LogsColumnDefs.js           # Column definitions and renderers
└── modals/
    ├── ColumnSelectorModal.jsx # Column visibility settings
    └── UserInfoModal.jsx       # User information display

web/src/hooks/logs/
└── useLogsData.js              # Custom hook for state & logic
```

### 🎯 Key Improvements
- **Maintainability**: Clear separation of concerns, easier to understand
- **Reusability**: Modular components can be reused independently
- **Performance**: Optimized with `useMemo` for column rendering
- **Testing**: Single-responsibility components easier to test
- **Developer Experience**: Better code organization and readability

### 🔧 Technical Details
- Preserved all existing functionality and user experience
- Maintained backward compatibility through existing import path
- Centralized all business logic in `useLogsData` custom hook
- Extracted column definitions to separate module with render functions
- Split complex UI into focused components (table, actions, filters, modals)

### 🐛 Fixes
- Fixed Semi UI component import issues (`Typography.Paragraph`)
- Resolved module export dependencies
- Maintained consistent prop passing patterns

## Breaking Changes
None - all existing imports and functionality preserved.
2025-07-18 22:04:54 +08:00
IcedTangerine
952b679ca3 Merge pull request #1352 from wzxjohn/feature/simple_stripe
Add stripe support and fix wrong top up loading state
2025-07-18 22:00:52 +08:00
t0ng7u
6799daacd1 🚀 feat(web/channels): Deep modular refactor of Channels table
1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components
   • `channels/index.jsx` – composition entry
   • `ChannelsTable.jsx` – pure `<Table>` rendering
   • `ChannelsActions.jsx` – bulk & settings toolbar
   • `ChannelsFilters.jsx` – search / create / column-settings form
   • `ChannelsTabs.jsx` – type tabs
   • `ChannelsColumnDefs.js` – column definitions & render helpers
   • `modals/` – BatchTag, ColumnSelector, ModelTest modals

2. Extract domain hook
   • Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js`
     – centralises state, API calls, pagination, filters, batch ops
     – now exports `setActivePage`, fixing tab / status switch errors

3. Update wiring
   • All sub-components consume data via `useChannelsData` props
   • Adjusted import paths after hook relocation

4. Clean legacy file
   • Legacy `components/table/ChannelsTable.js` now re-exports new module

5. Bug fixes
   • Tab switching, status filter & tag aggregation restored
   • Column selector & batch actions operate via unified hook

This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app.
2025-07-18 21:05:36 +08:00
Calcium-Ion
fa02b5150c Merge pull request #1362 from KamiPasi/patch-1
🔧 refactor(user_usable_group): enhance concurrency control with mutex…
2025-07-18 20:53:29 +08:00
Calcium-Ion
63a1904242 Merge pull request #1393 from feitianbubu/pr/fix-mysql-default-false
fix: mySQL does not support default false
2025-07-18 20:50:28 +08:00
Calcium-Ion
1e3450fdcb Merge pull request #1381 from feitianbubu/pr/task-origin-name
feat: video priority use origin model name
2025-07-18 20:49:53 +08:00
Calcium-Ion
5541026b86 Merge pull request #1190 from feitianbubu/fix-nil-request-id
fix: default request ID to 'SYSTEM' for background tasks
2025-07-18 20:48:15 +08:00
Calcium-Ion
c36c920b34 Merge pull request #1349 from feitianbubu/pr/fix-kling-image-mode-name
fix: KlingText2VideoRequest image and model_name
2025-07-18 20:47:10 +08:00
Calcium-Ion
514fea65c4 Merge pull request #1363 from feitianbubu/pr/add-jimeng-image
feat: 增加即梦生图功能
2025-07-18 20:45:49 +08:00
feitianbubu
e269b3bfdd fix: mySQL does not support default false 2025-07-18 13:52:16 +08:00
feitianbubu
0862a9bfa7 feat: add jimeng image -- fix alpha 2025-07-18 11:47:27 +08:00
t0ng7u
f43c695527 🗑️ refactor(table): remove custom formatPageText from all table components
Eliminated the manual `formatPageText` function that previously rendered
pagination text (e.g. “第 {{start}} - {{end}} 条,共 {{total}} 条”) in each
Table. Pagination now relies on the default Semi UI text or the global
i18n configuration, reducing duplication and making future language
updates centralized.

Why
---
* Keeps table components cleaner and more maintainable.
* Ensures pagination text automatically respects the app-wide i18n
  settings without per-component overrides.
2025-07-18 10:59:24 +08:00
t0ng7u
ead43f081c 🎉 feat(i18n): integrate Semi UI LocaleProvider with dynamic i18next language support
Add Semi UI internationalization to the project by wrapping the root
component tree with `LocaleProvider`. A new `SemiLocaleWrapper`
component maps the current `i18next` language code to the corresponding
Semi locale (currently `zh_CN` and `en_GB`) and falls back to Chinese
when no match is found.

Key changes
-----------
1. web/src/index.js
   • Import `LocaleProvider`, `useTranslation`, and Semi locale files.
   • Introduce `SemiLocaleWrapper` to determine `semiLocale` from
     `i18next.language` using a concise prefix-based mapping.
   • Wrap `PageLayout` with `SemiLocaleWrapper` inside the existing
     `ThemeProvider`.

2. Ensures that all Semi components automatically display the correct
   language when the app language is switched via i18next.

BREAKING CHANGE
---------------
Applications embedding this project must now ensure that `i18next`
initialization occurs before React render so that `LocaleProvider`
receives the correct initial language.
2025-07-18 10:55:05 +08:00
t0ng7u
4e2a3d61dc Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-18 09:38:19 +08:00
t0ng7u
218ad6bbe0 🎨 refactor(ui): scope table scrolling to console cards & refine overall layout
Summary
Implement a dedicated, reusable scrolling mechanism for all console-table pages while keeping header and sidebar fixed, plus related layout improvements.

Key Changes
• Added `.table-scroll-card` utility class
 – Provides flex column layout and internal vertical scrolling
 – Desktop height: `calc(100vh - 110px)`; Mobile (<768 px) height: `calc(100vh - 77px)`
 – Hides scrollbars cross-browser (`-ms-overflow-style`, `scrollbar-width`, `::-webkit-scrollbar`)
• Replaced global `.semi-card` scrolling rules with `.table-scroll-card` to avoid affecting non-table cards
• Updated table components (Channels, Tokens, Users, Logs, MjLogs, TaskLogs, Redemptions) to use the new class
• PageLayout
 – Footer is now suppressed for all `/console` routes
 – Confirmed only central content area scrolls; header & sidebar remain fixed
• Restored hidden scrollbar rules for `.semi-layout-content` and removed unnecessary global overrides
• Minor CSS cleanup & comment improvements for readability

Result
Console table pages now fill the viewport with smooth, internal scrolling and no visible scrollbars, while other cards and pages remain unaffected.
2025-07-18 01:06:18 +08:00
Xyfacai
b485f2e42e fix(image): 只有 dalle模型才受 size、quality 影响价格 2025-07-18 00:30:49 +08:00
t0ng7u
16e32c3f67 📚 docs: add comprehensive Web-interface API reference (excluding Relay)
A new Markdown file `docs/api/web_api.md` has been added that documents all backend REST endpoints used by the Web UI.

Details:
• Lists every `/api`, `/dashboard`, and `/v1/dashboard` route relevant to the Web front-end
• Excludes every Relay-specific path to keep scope focused on Web operations
• Groups endpoints by functional module (initialisation, public info, user, channel, token, logging, etc.)
• Specifies HTTP method, path, required auth level, and concise description for each entry
• Includes an auth-level legend and update-date placeholder for future maintenance

No application logic was modified; this is documentation-only and improves developer onboarding and API discoverability.
2025-07-17 23:50:56 +08:00
t0ng7u
15f65bb558 🌐 feat: add configurable USD exchange-rate support across backend & frontend
Backend
- setting/payment.go: introduce default `USDExchangeRate` (7.3)
- model/option.go:
  • inject `USDExchangeRate` into `InitOptionMap`
  • persist & sync value in `updateOptionMap`
- controller/misc.go: expose `usd_exchange_rate` via `/api/status`

Frontend
- OperationSetting.js & SettingsGeneral.js:
  • extend state/inputs with `USDExchangeRate`
  • add form field “美元汇率 (non-top-up rate, pricing only)”
- ModelPricing.js already consumes `status.usd_exchange_rate`; no change needed

API
- Administrators can update the rate via `PUT /api/option` (key: `USDExchangeRate`)
- All clients receive the latest rate through `GET /api/status`

This closes the end-to-end flow for displaying model prices in both USD and CNY based on a configurable exchange rate.
2025-07-17 23:04:45 +08:00
CaIon
b161d6831f fix: 修复playground优先级失效 2025-07-17 22:26:38 +08:00
CaIon
969953039f Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-17 22:25:26 +08:00
t0ng7u
f1506ed5da 💸 feat(model-pricing): add “show with recharge price” toggle and USD/CNY selector
* Introduced `showWithRecharge` switch in the actions bar to display model prices based on recharge cost.
* Added a `Select` dropdown (USD / CNY) that appears only when the recharge-price mode is enabled.
* Implemented `displayPrice()` helper to:
  * Convert USD prices to recharge prices using `status.price` and `status.usd_exchange_rate`.
  * Format output according to the selected currency.
* Updated price rendering for both quota types to use the new helper and respect K/M unit conversion.
* Removed the old currency switch from the header, retaining only the K/M unit toggle.
* Extended `SearchAndActions` memo dependencies; imported `Select` from Semi UI.
* Minor refactors and comment clean-up.

No breaking changes.
2025-07-17 22:07:06 +08:00
CaIon
9a239d9e13 refactor: initialize channel cache after channel operations to ensure data consistency 2025-07-17 20:04:26 +08:00
CaIon
a5da09dfb9 feat: 添加手动输入和文件上传模式切换功能,支持密钥搜索和高亮显示 2025-07-17 19:53:33 +08:00
CaIon
6f81f2d143 fix: 修复vertex渠道编辑密钥功能失效 2025-07-17 19:29:28 +08:00
Calcium-Ion
0b877ca8a3 Merge pull request #1383 from feitianbubu/pr/fix-gemini-and-claude-completions
fix: usage cost(any) to support claude and gemini
2025-07-17 19:16:55 +08:00
Calcium-Ion
2911b9cd04 Merge pull request #1368 from RedwindA/fix/embedding-test
fix: 修复Gemini渠道的向量模型测试
2025-07-17 19:16:10 +08:00
Calcium-Ion
6b3f1ab0e4 Merge pull request #1384 from QuantumNous/RequestOpenAI2ClaudeMessage
feat: 改进 RequestOpenAI2ClaudeMessage 和添加 claude web search 计费
2025-07-17 19:15:54 +08:00
Calcium-Ion
2c15655b08 Merge pull request #1387 from feitianbubu/alpha
fix: playground chat
2025-07-17 19:14:34 +08:00
t0ng7u
afa9c650fe 💱 feat(model-pricing): add currency (USD/CNY) & token unit (M/K) toggles to Model Pricing table
* Introduced a currency switch to toggle prices between USD and CNY.
  * CNY prices are calculated by multiplying USD prices with the site-wide `price` rate from `/api/status`.
* Added a second switch to display prices per 1 M tokens or per 1 K tokens.
  * When “K” is selected, prices are divided by 1 000 and labels are updated accordingly.
* Extended component state with `currency` and `tokenUnit` variables.
* Integrated `StatusContext` to retrieve and memoize the current exchange rate.
* Updated price rendering logic and labels to reflect selected currency and token unit.
* Minor UI tweaks: kept Switch components compact and aligned with the table header.

No breaking changes.
2025-07-17 19:07:11 +08:00
IcedTangerine
28d8d82ded Merge pull request #1369 from RedwindA/fix/xai-non-stream
fix xai-non-stream
2025-07-17 17:56:19 +08:00
feitianbubu
a100baf57f fix: playground chat 2025-07-16 23:47:59 +08:00
Glaxy
5621755655 Update relay-claude.go
优化claude messages接口启用思考时的参数设置
2025-07-16 18:00:33 +08:00
feitianbubu
d892bfc278 fix: usage cost(any) to support claude and gemini 2025-07-16 16:58:34 +08:00
feitianbubu
4369b18fbf feat: priority use origin model name 2025-07-16 15:38:51 +08:00
wzxjohn
fb9b5d31e8 Merge branch 'alpha' into feature/simple_stripe 2025-07-16 10:39:11 +08:00
同語
3bf0748389 refactor: layout logic to enhance front-end responsiveness
Merge pull request #1377 from QuantumNous/refactor/layout
2025-07-16 04:53:15 +08:00
t0ng7u
cf46b89814 🧹 refactor(PageLayout): remove unused state variables
Removed unused `userState` and `statusState` bindings from `PageLayout.js`
while retaining their dispatch functions for authentication and status
management. Confirmed correct use of array destructuring to skip the
unused toggle function returned by `useSidebarCollapsed`.

This change introduces no functional differences and solely improves
readability and maintainability.
2025-07-16 04:50:23 +08:00
t0ng7u
3360b34af9 🐛 fix: SSR hydration mismatch in mobile detection & clean up sidebar style
- Added a server-snapshot fallback (`() => false`) to `useIsMobile` to ensure
  consistent results between server-side rendering and the browser, preventing
  hydration mismatches.
- Removed a redundant ternary in `PageLayout` sidebar styles, replacing
  `isMobile ? 'fixed' : 'fixed'` with a single `'fixed'` value for clarity.

These changes improve SSR reliability and tidy up inline styles without
affecting runtime functionality.
2025-07-16 04:46:31 +08:00
t0ng7u
4558eb41fc 🎨 feat(sidebar): replace custom collapse button with Semi UI Button
* Replaced the handmade collapse/expand div with Semi UI `<Button>`
  * Uses `theme="outline"` and `type="tertiary"` for outlined style
  * Shows icon + text when sidebar is expanded, icon-only when collapsed
* Added `iconOnly` prop and dynamic padding to remove extra text area in collapsed state
* Ensured full-width layout with consistent padding for both states
* Updated imports to include `Button` from `@douyinfe/semi-ui`
* Maintains tooltip content for accessibility and better UX
2025-07-16 04:35:19 +08:00
t0ng7u
bbc5584f80 ♻️ refactor(auth, ui): simplify Loading component & optimize OAuth2Callback flow
* Removed `prompt` prop from `Loading` and switched to built-in Spin indicator with default size `small`
* Dropped overlay background to make the spinner more reusable
* Replaced custom text span; callers can now supply tip via their own UI if needed
* Cleaned up `OAuth2Callback`:
  - Eliminated unused state/variables
  - Added MAX_RETRIES with incremental back-off
  - Centralized error handling via try/catch
  - Streamlined navigation logic on success/failure
  - Updated imports to match new Loading signature

BREAKING CHANGE: `Loading` no longer accepts a `prompt` prop. Update all invocations accordingly.
2025-07-16 04:21:13 +08:00
t0ng7u
8604c9f9d5 feat(HeaderBar): ensure skeleton shows ≥500 ms and waits for real status data
The header’s skeleton screen now remains visible for at least 500 ms and
only disappears after `/api/status` has successfully populated
`StatusContext`.

Changes include:
• Added `loadingStartRef` to record the mount time.
• Reworked loading effect to compute the remaining delay based on the
  elapsed time and the presence of real status data.
• Removed the previous fixed‐timer logic, preventing premature content
  rendering and improving perceived loading consistency across pages.
2025-07-16 04:02:05 +08:00
t0ng7u
747e02ee0d 📱 feat(ui): auto-close sidebar on mobile after menu navigation
Adds a smoother mobile experience by automatically closing the sidebar
drawer once a menu item is tapped.

### Details
* SiderBar
  * Introduce `onNavigate` prop and invoke it on every `<Link>` click.
  * Remove unused `useIsMobile` hook and related `isMobile` variable.
* PageLayout
  * Pass `onNavigate` callback to `SiderBar` that sets `drawerOpen` to
    `false` when on mobile, ensuring the sidebar collapses after
    navigation.

This eliminates the “isMobile declared but never used” warning and
aligns the behaviour of the sidebar with common mobile UX expectations.
2025-07-16 03:52:40 +08:00
t0ng7u
8b0334309b 💄 style(layout): add horizontal padding to top-offset divs for consistent spacing
Replaced every instance of
<div className="mt-[64px]">
with
<div className="mt-[64px] px-2">
to provide uniform horizontal padding across pages. No functional changes—visual layout improvement only.
2025-07-16 03:42:19 +08:00
t0ng7u
48afa821e4 🖌️ feat(ui): always show colorful console banner for NewAPI in every environment
Add a branded console log to `web/src/index.js` that prints:

“We ❤ NewAPI  Github: https://github.com/QuantumNous/new-api”

Changes include:
• Remove the `NODE_ENV` guard so the banner appears in both development and production.
• Increase font size to 24 px and keep “NewAPI” in bold green for stronger branding.

This is a purely visual/developer-experience enhancement—no runtime behavior is affected.
2025-07-16 03:38:06 +08:00
t0ng7u
42a8d3e3dc 🔧 chore(router): enable React Router v7 future flags to suppress warnings
Add `future` prop to `BrowserRouter` in `web/src/index.js` with
`v7_startTransition` and `v7_relativeSplatPath` turned on.
This opts the app into upcoming React Router v7 behavior, removes
console warnings, and readies the codebase for a smoother future upgrade.

No runtime behavior changes; developer-experience improvement only.
2025-07-16 03:08:04 +08:00
t0ng7u
a44fc51007 📱 refactor(web): remove legacy isMobile util and migrate to useIsMobile hook
BREAKING CHANGE:
helpers/utils.js no longer exports `isMobile()`.
Any external code that relied on this function must switch to the `useIsMobile` React hook.

Summary
-------
1. Deleted the obsolete `isMobile()` function from helpers/utils.js.
2. Introduced `MOBILE_BREAKPOINT` constant and `matchMedia`-based detection for non-React contexts.
3. Reworked toast positioning logic in utils.js to rely on `matchMedia`.
4. Updated render.js:
   • Removed isMobile import.
   • Added MOBILE_BREAKPOINT detection in `truncateText`.
5. Migrated every page/component to the `useIsMobile` hook:
   • Layout: HeaderBar, PageLayout, SiderBar
   • Pages: Home, Detail, Playground, User (Add/Edit), Token, Channel, Redemption, Ratio Sync
   • Components: ChannelsTable, ChannelSelectorModal, ConflictConfirmModal
6. Purged all remaining `isMobile()` calls and legacy imports.
7. Added missing `const isMobile = useIsMobile()` declarations where required.

Benefits
--------
• Unifies mobile detection with a React-friendly hook.
• Eliminates duplicated logic and improves maintainability.
• Keeps non-React helpers lightweight by using `matchMedia` directly.
2025-07-16 02:54:58 +08:00
creamlike1024
961bc874d2 feat: claude web search tool 计费 2025-07-15 18:57:22 +08:00
t0ng7u
b2b018ab93 🚀 feat(frontend): add robust boolean handling across settings pages
Summary
-------
1. Introduced a reusable `toBoolean` utility (`web/src/helpers/boolean.js`) that converts
   strings (`'true'/'false'`, `'1'/'0'`), numbers, and native booleans to a proper boolean.
2. Re-exported `toBoolean` via `web/src/helpers/index.js` for simple one-line imports.

Refactors
---------
• Systematically replaced all legacy `item.value === 'true'` checks with `toBoolean(item.value)` in
  the following components:
  – `SystemSetting.js`
  – `OperationSetting.js`
  – `PaymentSetting.js`
  – `RatioSetting.js`
  – `RateLimitSetting.js`
  – `ModelSetting.js`
  – `DrawingSetting.js`
  – `DashboardSetting.js`
  – `ChatsSetting.js`

• Unified import statements to
  `import { …, toBoolean } from '../../helpers';`
  removing redundant `../../helpers/boolean` paths.

Why
---
SQLite sometimes returns `1/0` or boolean literals instead of the string `'true'/'false'`, causing
checkbox states to reset on page reload. The new utility guarantees consistent boolean parsing,
fixing the issue across all environments (SQLite, MySQL, etc.) while improving code clarity.
2025-07-15 17:18:48 +08:00
creamlike1024
77da33de4f feat: RequestOpenAI2ClaudeMessage add more parms map 2025-07-15 12:38:05 +08:00
t0ng7u
06ad5e3f8c 🐛 fix: multi-key channel sync and Vertex-AI key-upload edge cases
Backend
1. controller/channel.go
   • Always hydrate `ChannelInfo` from DB in `UpdateChannel`, keeping `IsMultiKey` true so `MultiKeySize` is recalculated.

2. model/channel.go
   • getKeys(): accept both newline-separated keys and JSON array (`[ {...}, {...} ]`).
   • Update(): reuse new parser-logic to recalc `MultiKeySize`; prune stale indices in `MultiKeyStatusList`.

Frontend
1. pages/Channel/EditChannel.js
   • `handleVertexUploadChange`
     – Reset `vertexErroredNames` on every change so the “ignored files” prompt always re-appears.
     – In single-key mode keep only the last file; in batch mode keep all valid files.
     – Parse files, display “以下文件解析失败,已忽略:…”.
   • Batch-toggle checkbox
     – When switching from batch→single while multiple files are present, show a confirm dialog and retain only the first file (synchronises state, form and local caches).
   • On opening the “new channel” side-sheet, clear `vertexErroredNames` to restore error prompts.

Result
• “已启用 x/x” count updates immediately after editing multi-key channels.
• Vertex-AI key upload works intuitively: proper error feedback, no duplicated files, and safe down-switch from batch to single mode.
2025-07-15 12:02:04 +08:00
t0ng7u
9326bf96fc style(ui): Change the size of the icons in the dashboard to normal 2025-07-14 23:47:45 +08:00
t0ng7u
bed73102b4 feat: Add fade-in animation for greeting message
- Add greetingVisible state to control animation trigger
- Implement fade-in effect with 1-second smooth transition
- Set 100ms delay before animation starts
- Apply opacity transition from 0 to 1 using ease-in-out timing
- Enhance user experience with smooth visual feedback on page load

The greeting message now appears with an elegant fade-in animation,
transitioning from transparent to fully visible over 1 second,
providing better visual appeal and user engagement.
2025-07-14 23:42:03 +08:00
t0ng7u
eb59f9c75d feat(ui): enhance loading states and fix layout issues
- Fix uptime service card bottom spacing by removing flex layout
- Replace IconRotate with IconSend for request count to better represent semantic meaning
- Add skeleton loading placeholders for all dashboard statistics with 500ms minimum duration
- Unify avgRPM and avgTPM calculation with consistent NaN handling
- Standardize skeleton usage across HeaderBar and Detail components with active animations
- Remove unnecessary empty wrapper elements in skeleton implementations
- Remove gradient styling from system name in header

The changes improve user experience with consistent loading states, better semantic icons,
and eliminate visual layout issues in the dashboard cards.
2025-07-14 23:31:01 +08:00
t0ng7u
f3bd2ed472 💄 fix(ui): improve uptime card layout and request count icon
- Remove flex layout from uptime service card to eliminate bottom spacing
- Remove flex-1 and mt-auto classes that caused unnecessary stretching
- Replace IconRotate with IconSend for request count to better represent the semantic meaning
- Legend now sits directly below content instead of being pushed to bottom

Fixes the visual issue where uptime service availability card had unwanted white space at the bottom when content was minimal.
2025-07-14 22:52:00 +08:00
Xyfacai
456475d593 refactor: format api page query and err result 2025-07-14 22:03:22 +08:00
t0ng7u
a36ce199ba feat: implement backend channel duplication & streamline frontend copy flow
Add a dedicated backend endpoint to clone an existing channel (including its key) and
replace all previous front-end cloning logic with a single API call.

Backend
• controller/channel.go
  – add CopyChannel: safely clone a channel, reset balance/usage, append name suffix,
    preserve key, create abilities, return new ID.
  – supports optional query params: `suffix`, `reset_balance`.
• router/api-router.go
  – register POST /api/channel/copy/:id (secured by AdminAuth).
• model interaction uses BatchInsertChannels to ensure transactional integrity.

Frontend
• ChannelsTable.js
  – simplify copySelectedChannel: call /api/channel/copy/{id} and refresh list.
  – remove complex field-manipulation & key-fetching logic.
  – improved error handling.

Security & stability
• All cloning done server-side; sensitive key never exposed to client.
• Route inherits existing admin middleware.
• Graceful JSON responses with detailed error messages.
2025-07-14 21:54:53 +08:00
RedwindA
b7c3ad0867 🐛 fix: Add nil check for xaiResponse.Usage before calculating CompletionTokens 2025-07-14 21:40:15 +08:00
RedwindA
ea3545cc7e 🐛 fix: Use correct dto for non-stream xai 2025-07-14 21:32:31 +08:00
t0ng7u
232ba46b16 🚀 feat(detail): enhance API Info list with jump button & responsive layout
* Added an “Jump” (`ExternalLink`) tag to each API entry that opens the URL in a new tab
* Placed “Speed Test” and “Jump” tags on the same line as the route
  * Route is left-aligned; tags are right-aligned and wrap to next line when space is insufficient
* Inserted `<Divider />` between API items to improve visual separation
* Tweaked flex gaps and utility classes for consistent spacing and readability
2025-07-14 20:22:09 +08:00
t0ng7u
5f011502d1 🍎style(button): Optimize the colors of all buttons to reduce visual fatigue 2025-07-14 19:49:40 +08:00
RedwindA
93b6f1066b 🎨 feat(channel-test): Enhance request conversion for Embedding models and update input type 2025-07-14 16:59:48 +08:00
t0ng7u
52fe92ed7f 🐛 fix: Ensure channel tooltip displays correctly in LogsTable
* Refactored the render logic of the **Channel** column
  * Wrapped tooltip around the first `Tag` only to restore hover behavior when multi-key mode is enabled
  * Added i18n-friendly fallback (`t('未知渠道')`) for cases where `channel_name` is missing
* Improves user experience for administrators by reliably showing channel names even with multiple keys
2025-07-14 13:12:40 +08:00
t0ng7u
0d005df463 🎨 feat(ui): Change TolenTables Progress Style marginTop 0 to 1px 2025-07-13 19:34:20 +08:00
t0ng7u
e3ef3ace29 🎨 feat(ui): Enhance model dropdowns with icons in Token & Channel editors
Summary
• Added visual model icons to dropdown options in both Token (`EditToken.js`) and Channel (`EditChannel.js`) editors.

Details
1. Token Editor
   - Imported `getModelCategories` from helpers.
   - Re-built Model Limits option list to prepend the matching icon to each model label.

2. Channel Editor
   - Imported `getModelCategories`.
   - Extended model‐option construction to include icons, unifying behaviour with Token editor.
   - Maintained existing logic for merging origin options and user-selected models.

Benefits
• Provides immediate visual identification of model vendors.
• Aligns UX with existing icon usage across the application, improving consistency and clarity.
• No functional changes to data handling; purely UI/UX enhancement.

Co-authored-by: [Your Name]
2025-07-13 19:32:01 +08:00
t0ng7u
a203e98689 💄 feat(ui): Enhance TokensTable quota presentation and clean up code
Summary
• Re-architect the status column to embed quota information directly inside the Tag suffix.
• Consolidate rendering logic for clearer structure and easier maintenance.

Details
• Moved the quota Progress bar under the remaining / total text, inside `quotaSuffix`.
• Added “Unlimited” label for tokens with `unlimited_quota`; hides Progress and Tooltip in this case.
• Tightened vertical spacing with a flex container (`gap-[2px]`, `leading-none`) and removed extra wrappers; Progress now has zero top/bottom margin and full-width style.
• Refactored variables:
  – Replaced `tagNode/bodyContent` with a single `content` node.
  – Wrapped `content` with Tooltip only when quota is limited.
• Visual tweaks:
  – Applied `size='large'` to the Tag for better alignment.
  – Ensured consistent color via shared `getProgressColor`.
• Deleted obsolete comments and redundant code.

Result
Improves readability of the component and delivers a cleaner, more compact quota display.
2025-07-13 19:14:43 +08:00
t0ng7u
27f99a0f38 🔍 feat(edit-token): add search capability to model limit selector
Changes:
• `Form.Select` for “Model Limits” now supports in-dropdown searching (`filter` + `searchPosition='dropdown'`) enabling quick model lookup.
• Removed `maxTagCount` to display all selected models without truncation.

Benefit: simplifies selecting specific models when the list is large, improving usability during token creation/editing.
2025-07-13 18:25:05 +08:00
t0ng7u
d1e48d02bd 🎨 style(tokens-table): enhance quota usage progress bar UI
Improves the “Balance” column in `TokensTable`:

• Re-styled progress bar: wider container (`w-[140px]`) and `showInfo` enabled to display percentage label.
• Added `format={() => \`\${percent}%\`}` for precise percentage feedback.
• Maintains dynamic color mapping (success / warning / danger) for clarity.

Result: clearer, more informative visual representation of remaining token quota.
2025-07-13 18:10:59 +08:00
t0ng7u
4f06a1df50 🔄 fix(tables): keep current page after edits & auto-fallback when page becomes empty
Includes ChannelsTable, RedemptionsTable and UsersTable:

• Refactor `refresh(page = activePage)` in all three tables so data reloads the requested (or current) page instead of forcing page 1.
• On single-row deletion (and bulk deletion in ChannelsTable):
  – Refresh current page immediately.
  – If the refreshed page has no data and `activePage > 1`, automatically load the previous page to avoid blank views.
• RedemptionsTable: corrected prior bug where `refresh` used `activePage - 1`.
• Misc: removed outdated inline comments and aligned search / reset flows.

Result: smoother UX—users stay on their working page, and pagination gracefully adjusts after deletions.
2025-07-13 17:45:31 +08:00
t0ng7u
2d7ae1180f feat(tokens-table): show “Other” avatar for models without vendor logo
Adds UI fallback in TokensTable “Available Models” column:
• Tracks models already matched to a known vendor icon.
• Collects unmatched models and renders a neutral “Other” avatar (labelled via `t('其他')`) with a tooltip listing the model names.
• Removes previous generic fallback so every model is now either vendor-specific or grouped under “Other”.

This improves clarity for users by explicitly indicating models from unrecognized providers rather than leaving them unlabelled.
2025-07-13 17:24:55 +08:00
CaIon
75b486b467 feat: comment out model validation in ConvertClaudeRequest method 2025-07-13 15:17:15 +08:00
CaIon
5b5f10fe93 🔄 update: add bun.lock file copy to Dockerfile for dependency management
- Included the `bun.lock` file in the Dockerfile to ensure consistent dependency installation during the build process.
2025-07-13 14:05:45 +08:00
CaIon
5f654e76e2 🔄 update: downgrade several Babel and Astro dependencies in bun.lock
- Updated `@astrojs/compiler`, `@babel/code-frame`, `@babel/compat-data`, `@babel/core`, `@babel/generator`, `@babel/helper-compilation-targets`, `@babel/helper-define-polyfill-provider`, `@babel/helper-module-imports`, `@babel/helper-module-transforms`, `@babel/plugin-transform-runtime`, `@babel/runtime`, and other related packages to earlier versions to ensure compatibility and stability.
- Adjusted various dependencies within the `bun.lock` file to reflect these changes.
2025-07-13 14:00:12 +08:00
feitianbubu
aa8d112c58 feat: add jimeng image 2025-07-13 13:36:33 +08:00
CaIon
e82dc0e841 feat: add bun.lock file to manage dependencies and remove bun.lockb
- Introduced a new `bun.lock` file to track project dependencies and their versions.
- Deleted the outdated `bun.lockb` file to streamline dependency management.
2025-07-13 12:16:18 +08:00
CaIon
dd741fc38a 🔄 update: regenerate bun.lockb file to reflect dependency changes 2025-07-13 12:08:13 +08:00
KamiPasi
120e4ee92f 🔧 refactor(user_usable_group): enhance concurrency control with mutex for user groups management 2025-07-13 01:03:56 +08:00
t0ng7u
9d2a56bff4 🎨 refactor: TokensTable UI for clearer quota info & compact controls
• Display remaining-quota percentage instead of used-quota in the Progress indicator
  - 100 % when quota is untouched, shown in green
  - Warn at ≤ 30 % (yellow) and at ≤ 10 % (red)
  - Hide internal label (`showInfo={false}`) and move the percentage text into the Tooltip
  - Switch Progress `size` to `small` for a lighter visual footprint

• Update Tooltip to list used, remaining, total quota and the new percentage value

• Uniformly set `size="small"` on all header Buttons and Form inputs within the table
  — enhances readability and keeps the main content centered

UI/UX improvement only; no backend logic affected.
2025-07-13 01:02:55 +08:00
t0ng7u
31d82a3169 🚑 fix: safeguard NewAPIError.Error() against nil pointer panic
Backend
• `types/error.go`
  – Return empty string when receiver itself is `nil`.
  – If `Err` is `nil`, fall back to `errorCode` string to avoid calling `nil.Error()`.

This prevents runtime panics when the error handler builds an OpenAI-style error response but the underlying `Err` field has not been set.
2025-07-13 00:16:38 +08:00
t0ng7u
d22ee5d451 🐛 fix: preserve key data when editing channels & update MultiKeySize correctly
Backend
• `model/channel.go`
  – On multi-key channel updates, recalculate `MultiKeySize` from the current key list.
  – If the request omits `key`, fetch existing keys from DB to avoid resetting the count to `0/0`.
  – Remove out-of-range entries from `MultiKeyStatusList` to keep data consistent.

Frontend
• `web/src/pages/Channel/EditChannel.js`
  – Vertex AI channels no longer require re-uploading a key file in edit mode.
  – When no new key is provided during editing, exclude the `key` field from the payload to prevent overwriting the stored value.

These changes ensure that editing a channel no longer zeroes out the enabled key counter and that Vertex AI channels can be modified without mandatory key re-submission.
2025-07-13 00:09:27 +08:00
t0ng7u
203edaed50 🐛 fix: synchronize MultiKeySize after editing multi-key channels
1. Recalculate `ChannelInfo.MultiKeySize` based on the current key list when a multi-key channel is updated.
2. Purge any indexes in `MultiKeyStatusList` that exceed the new key count to avoid stale or out-of-range data.

This ensures the front-end display (`enabled x/x`) always reflects the real number of keys after users add or remove keys in edit mode.
2025-07-12 23:57:59 +08:00
t0ng7u
93b5638a9c Merge remote-tracking branch 'origin/multi_keys_channel' into alpha
# Conflicts:
#	web/src/components/table/LogsTable.js
#	web/src/i18n/locales/en.json
#	web/src/pages/Channel/EditChannel.js
2025-07-12 23:47:24 +08:00
CaIon
52a5e58f0c feat(adaptor): refactor response handlers to return usage first and improve error handling 2025-07-12 21:12:46 +08:00
CaIon
20607b0b5c feat(logs): add multi-key support in LogsTable and enhance log info generation 2025-07-12 15:14:55 +08:00
Calcium-Ion
6bebfe9e54 Merge pull request #1357 from RedwindA/feat/xAI-search
feat: support xAI-search
2025-07-12 14:28:14 +08:00
CaIon
50b76f4466 feat(channel): improve channel cache handling and add error checks for disabled channels 2025-07-12 14:20:59 +08:00
CaIon
23e4e25e9a feat(channel): implement thread-safe polling 2025-07-12 11:17:08 +08:00
t0ng7u
5b83d478d6 🎨 style(footer): remove redundant bg-semi-color-bg-2 class (#1354)
The background utility class `bg-semi-color-bg-2` was unnecessary after recent
design adjustments and unintentionally overrode intended styles.
Removing it cleans up the markup and allows the footer to inherit the correct
background from its parent containers.

File affected:
- web/src/components/layout/Footer.js

Closes #1354
2025-07-12 03:53:06 +08:00
t0ng7u
dca38d01d6 🐛 fix(tokens-table): show all extra IP addresses in tooltip
Previously, the tooltip that appears when more than one IP address is configured
skipped the second IP (`ips.slice(2)`), so users could not see it unless they
expanded the list in another way.
Changed the slice start index to `1`, ensuring that **every IP after the first
display tag** is included in the tooltip (`ips.slice(1).join(', ')`).

File affected:
- web/src/components/table/TokensTable.js
2025-07-12 03:48:55 +08:00
t0ng7u
0a434d3b3a ⏱️ fix(token): disallow selecting expiration date earlier than now
Add custom validator to the `expired_time` DatePicker in `EditToken.js`
to ensure that selected expiration timestamps are strictly in the future.

- Introduce Promise-based validator that:
  • Rejects invalid date formats with a clear error message
  • Prevents past or current dates and shows “Expiration time cannot be earlier than the current time!”
- Keeps support for the special “never expires” value (-1)
- Blocks form submission until a valid future datetime is provided

This change prevents accidental creation of already expired tokens and
improves overall robustness of token management.
2025-07-12 03:42:05 +08:00
t0ng7u
7c4b83a430 🎨 refactor(TokensTable): refactor TokensTable UI & UX for clearer data and inline actions
This commit overhauls the `TokensTable` component to deliver a cleaner, more intuitive experience.

Key changes
1. Quota
   • Merged “Used” & “Remaining” into a single “Quota” column.
   • Uses a circular `Progress` with %-label; full details shown on tooltip.

2. Status
   • Tag now embeds a small `Switch` (prefixIcon) to enable/disable a token in-place.
   • Removed enable/disable actions from the old dropdown.

3. Columns & layout
   • Added dedicated “Group” column (moved from Status).
   • Added “Key” column:
     – Read-only `Input` styled like Home page base-URL field.
     – Masked value (`sk-abc********xyz`) by default.
     – Eye button toggles reveal/hide; Copy button copies full key (without modal).
   • Dropped “More” menu; Delete is now a direct button in the action area.

4. Model limits
   • Shows vendor icons inside an `AvatarGroup`; tooltip lists the exact models.

5. IP restriction
   • Displays first IP, extra count as “+N” Tag with tooltip.
   • Unlimited shows white Tag.

6. Cleanup / misc.
   • Removed unused helpers (`getQuotaPerUnit`), icons (`IconMore`, eye/copy duplicates, etc.).
   • Replaced legacy modal view of key with inline input behaviour.
   • Tweaked paddings, themes, sizes to align with design system.

BREAKING CHANGE: Table column order & names have changed; update any tests or docs referencing the old structure.
2025-07-12 03:35:19 +08:00
RedwindA
b7f24b428b fix: 调整初始化顺序以避免写入日志失败 2025-07-12 02:23:10 +08:00
RedwindA
22a0ed0ee2 feat: 支持 xAI 网络搜索 2025-07-12 02:22:40 +08:00
t0ng7u
cf711d55a5 🎨 feat(ui): add icon support for Kling (50) & Jimeng (51) channels and iconize type selector
- import Kling & Jimeng icons from @lobehub/icons
- extend getChannelIcon() to return corresponding icons for new channel types 50 (Kling) and 51 (Jimeng)
- enhance EditChannel type selector:
  • introduce useMemo‐based channelOptionList with leading icons
  • utilize getChannelIcon for consistent icon rendering in dropdown options
- minor refactor: add useMemo and getChannelIcon imports in EditChannel.js
2025-07-12 00:02:12 +08:00
t0ng7u
26ea562fdb 🎨 style(ui): move type-specific “Other / Organization” inputs to Basic Info card for smoother channel setup
Summary
• **EditChannel.js**
  – Relocated the following conditional inputs from “Advanced Settings” to the initial “Basic Info” card:
    • Model version (type 18)
    • Deployment region (type 41)
    • Knowledge-base ID (type 21)
    • Account ID (type 39)
    • Agent ID (type 49)
    • OpenAI organization (type 1)
  – No functional changes; layout only.

Why
These fields are commonly filled during the first steps of channel creation. Surfacing them earlier reduces scrolling and streamlines the user workflow.
2025-07-11 23:47:13 +08:00
t0ng7u
efce0c6c57 🌐 i18n: add missing English strings for auto-disable switch in channel settings
Summary
• **en.json**
  – Added translation for “是否自动禁用” → “Whether to automatically disable”.
  – Stubbed/added key for helper text “仅当自动禁用开启时有效,关闭后不会自动禁用该渠道” (translation placeholder left for future update).

Why
These keys were previously untranslated, causing mixed-language UI for the “Auto Disable” toggle in the channel edit form. Filling them (or at least registering the keys) ensures consistent localization and prevents runtime fallbacks.
2025-07-11 23:43:27 +08:00
t0ng7u
a3768dae97 feat: enable fetching model list in creation mode & refine toast-based error handling
Summary
• **EditChannel.js**
  – Displays “Fetch Model List” button for both *create* and *edit* modes (removed `isEdit` guard).
  – Unified model selector placeholder to “Please choose supported models”.
  – Added null-safety checks when parsing Axios responses.
  – Sends requests with `{ skipErrorHandler: true }`, preventing generic *500 Internal Server Error* toasts and relying on context-specific messages instead.

• **helpers/api.js**
  – Introduced `skipErrorHandler` flag in the Axios response interceptor.
    If present, global `showError` is bypassed, giving callers full control over user-facing notifications.
  – Ensures `Promise.reject(error)` is returned for proper error propagation.

Why
Channel creators now enjoy the same convenience as editors when importing upstream model lists.
Meanwhile, suppressing redundant toasts removes confusion caused by simultaneous custom and generic error messages.
2025-07-11 23:37:47 +08:00
CaIon
85efea3fb8 feat(channel): implement multi-key mode handling and improve channel update logic 2025-07-11 21:12:17 +08:00
t0ng7u
c820fda26d 🌐 feat(system-settings): add ServerAddress option & full i18n support
This commit brings System Settings in line with Payment Settings and
prepares the whole component for multi-language deployments.

Key points
----------
• Introduced a “General Settings” card in `SystemSetting.js`
  - Adds a `ServerAddress` field (copied from Payment Settings).
  - Explains that this URL is used for payment callback & default homepage.
  - Implements `submitServerAddress` to persist the option.

• Internationalisation
  - Imported `useTranslation` and wrapped **all** Chinese UI strings in `t()`.
  - Localised section titles, labels, placeholders, banners, buttons,
    modal texts and toast messages.
  - Dynamic banner/tool-tip descriptions now combine `t()` fragments with
    template literals for runtime URLs.

• UX improvements
  - Added contextual `extraText` under the ServerAddress input.
  - Ensured placeholders like “sensitive info…” are translatable.

With these changes, administrators can configure the server URL from the
System Settings tab, and the entire page is ready for seamless language
switching via the existing i18n framework.
2025-07-11 02:53:53 +08:00
wzxjohn
4740293640 fix: for AI review problems 2025-07-10 21:03:24 +08:00
wzxjohn
8be8813cd8 fix: for AI review problems 2025-07-10 21:00:43 +08:00
wzxjohn
8cc747ef22 fix: for AI review problems 2025-07-10 20:55:43 +08:00
wzxjohn
d6ed2ab3e0 fix: wrong loading state while top up 2025-07-10 20:31:40 +08:00
wzxjohn
e8ae980104 feat: add stripe topup page 2025-07-10 20:28:29 +08:00
CaIon
cd8c23c0ab feat(channel): enhance channel status management 2025-07-10 17:49:53 +08:00
wzxjohn
3568042cd9 feat: add stripe setting page 2025-07-10 16:59:30 +08:00
wzxjohn
7443129e18 feat: add stripe pay api support 2025-07-10 16:29:38 +08:00
CaIon
a9e03e6172 🔧 refactor(user_cache): remove unused JSON import 2025-07-10 15:12:57 +08:00
CaIon
cb16bf552e Merge branch 'alpha' into refactor_error
# Conflicts:
#	controller/channel.go
#	middleware/distributor.go
#	model/channel.go
#	model/user.go
#	model/user_cache.go
#	relay/common/relay_info.go
2025-07-10 15:11:55 +08:00
CaIon
98952198bb refactor: Introduce standardized API error
This commit refactors the application's error handling mechanism by introducing a new standardized error type, `types.NewAPIError`. It also renames common JSON utility functions for better clarity.

Previously, internal error handling was tightly coupled to the `dto.OpenAIError` format. This change decouples the internal logic from the external API representation.

Key changes:
- A new `types.NewAPIError` struct is introduced to serve as a canonical internal representation for all API errors.
- All relay adapters (OpenAI, Claude, Gemini, etc.) are updated to return `*types.NewAPIError`.
- Controllers now convert the internal `NewAPIError` to the client-facing `OpenAIError` format at the API boundary, ensuring backward compatibility.
- Channel auto-disable/enable logic is updated to use the new standardized error type.
- JSON utility functions are renamed to align with Go's standard library conventions (e.g., `UnmarshalJson` -> `Unmarshal`, `EncodeJson` -> `Marshal`).
2025-07-10 15:02:40 +08:00
CaIon
338e914a60 🔧 refactor(channel): add validation for CHANNEL_TEST_FREQUENCY in automatic tests 2025-07-10 11:31:07 +08:00
skynono
4196a3db5a fix: KlingText2VideoRequest image and model_name
feat: add video channel kling swag
2025-07-09 07:34:04 +00:00
Calcium-Ion
78fb457765 Merge pull request #1346 from QuantumNous/fix-ability
 feat(ability): enhance FixAbility function
2025-07-08 18:38:35 +08:00
CaIon
8759ef012f feat(ability): enhance FixAbility function 2025-07-08 18:33:32 +08:00
Calcium-Ion
f8d67a62a2 Merge pull request #1334 from duyazhe/fix-baidu-bug
修复了百度请求时候需要传appid的bug
2025-07-07 14:51:23 +08:00
Xyfacai
efb98854b2 Merge pull request #1341 from QuantumNous/refactor/log-params
refactor: log params and channel params
2025-07-07 14:29:16 +08:00
Xiangyuan-liu
7b29f429ee refactor: log params and channel params
refactor: log params and channel params
2025-07-07 14:26:37 +08:00
CaIon
265c7d93a2 🔧 refactor(adaptor): update HTTP referer to new API domain 2025-07-07 12:36:04 +08:00
duyazhe
ce57ad3570 Update adaptor.go 2025-07-07 09:57:20 +08:00
t0ng7u
0e6b608f91 🎨 feat(ui): dynamic multi-key controls in Channel editor
Summary
1. Load `channel_info` when editing:
   • Detect if the channel is in multi-key mode (`is_multi_key`).
   • Auto-initialize `batch`, `multiToSingle`, and `multiKeyMode` from backend data.

2. Visibility logic
   • Creation page: “Batch create / Multi-key mode” always available.
   • Edit page: show these controls **only when** the channel itself is multi-key.

3. State consistency
   • `multi_key_mode` added to `inputs`; `setValues(inputs)` now preserves the user’s selection.

Result
Single-key channels no longer display irrelevant “key aggregation” options, while multi-key channels open with the correct defaults, providing a cleaner and more accurate editing experience.
2025-07-07 01:53:19 +08:00
t0ng7u
f1856fe4d2 🔧 **fix(model, controller): robust serialization for ChannelInfo & correct Vertex-AI key storage**
Summary
1. **model/channel.go**
   • Replaced the pointer-only `Value()` with a value-receiver implementation
   • GORM can now marshal both `ChannelInfo` and `*ChannelInfo`, eliminating
     `unsupported type model.ChannelInfo` runtime error.

2. **controller/channel.go**
   • Refactored `getVertexArrayKeys()` – every element is now
     - passed through `json.Marshal` when not already a string
     - trimmed & validated before insertion
   • Guarantees each service-account key is persisted as a **pure JSON string**
     instead of the previous `map[...]` dump.

Result
• Channel creation / update succeeds without SQL driver errors.
• Vertex-AI batch & multi-key uploads are stored in canonical JSON, ready for
  downstream SDKs to consume.
2025-07-07 01:31:41 +08:00
t0ng7u
870cdd5a56 feat(channel): Robust Vertex AI batch-key upload & stable multi-key settings
Summary
-------
1. Vertex AI JSON key upload
   • Accept multiple `.json` files (drag & drop / click).
   • Parse each `fileInstance`; build valid key array.
   • Malformed files are skipped and collected into **one** toast message.
   • Upload list now仅displays valid files; form state (`vertex_files`) 同步保持.
   • On *Submit* keys are re-parsed to prevent async timing loss.

2. Multi-key mode stability
   • Added `multi_key_mode: "random"` to initial form values.
   • `Form.Select` becomes fully controlled (`value`/`onChange`).
   • Toggling the “multi-key mode” checkbox writes default / removes field, so
     `setValues(inputs)` no longer clears the user’s choice.

3. UX / compatibility tweaks
   • Preserve uploaded files when editing any other field.
   • Use `fileInstance` only – compatible with legacy Semi Upload API.
   • Removed redundant `limit` prop on `Form.Upload`.
   • Aggregated error handling avoids toast spam.

Result
------
Channel creation/update now supports:
• Reliable batch import of Vertex AI service-account keys.
• Consistent retention of multi-key strategy (`random` / `polling`).
• Cleaner, user-friendly error feedback.
2025-07-07 01:12:01 +08:00
duyazhe
9282f1d893 修复了百度请求时候需要传appid的bug 2025-07-06 23:09:49 +08:00
CaIon
9546a47f2b feat(tokens): add cherryConfig support for URL generation and base64 encoding 2025-07-06 20:56:09 +08:00
CaIon
f0f277dc2a 🔧 refactor(auth, channel, context): improve context setup and validation for multi-key channels 2025-07-06 12:37:56 +08:00
CaIon
b695e67154 🔧 refactor(channel): replace common.ChannelTypeVertexAi with constant.ChannelTypeVertexAi 2025-07-06 10:35:29 +08:00
CaIon
fa2cd85007 Merge branch 'alpha' into mutil_key_channel
# Conflicts:
#	controller/channel.go
#	docker-compose.yml
#	web/src/components/table/ChannelsTable.js
#	web/src/pages/Channel/EditChannel.js
2025-07-06 10:33:48 +08:00
CaIon
8073cbd96a 🔧 refactor(model): change user group retrieval to non-strict mode 2025-07-06 10:23:38 +08:00
CaIon
5eba2f1d61 🔧 refactor(model): update context key retrieval to use token group instead of user group 2025-07-05 16:40:49 +08:00
Calcium-Ion
5ec421d8e6 Merge pull request #1321 from iszcz/main
支持Midjourney视频任务和图片编辑
2025-07-05 15:28:33 +08:00
iszcz
660180ea1b 支持Midjourney视频任务和图片编辑 2025-06-30 22:31:12 +08:00
CaIon
4a8b7bfa37 temp 2025-06-16 21:01:57 +08:00
CaIon
7403df7e9c feat(channel): enhance channel handling with multi-key support 2025-06-16 02:30:46 +08:00
Apple\Apple
617c8e8f4f feat(channel-ui): support multi-JSON batch creation for Vertex AI & multi-to-single mode
WHY
• Backend (0089157) now accepts structured request `{ mode, channel }`, including new `multi_to_single`.
• Need front-end to upload multiple service-account JSON files and generate correct `channel.key`.
• Improve UX: avoid red “uploadFail” state and offer drag-and-drop UI.

WHAT
1. EditChannel.js
   • Added Upload drag-area with IconBolt; `uploadTrigger="custom"`.
   • `handleJsonFileUpload` reads file, pushes content to `jsonFiles`, returns `{ shouldUpload:false, status:'success' }`.
   • New states: `batch`, `mergeToSingle`, `jsonFiles`.
   • Dynamic mode resolver: `single` | `batch` | `multi_to_single`.
   • Builds `channel.key` as JSON-object whose keys are the raw credential texts.
   • UI:
     – “Batch create” checkbox (new build only).
     – Nested “Merge to single channel (multi-key mode)” checkbox enabled when batch=true.
     – Real-time file count display.

2. Upload UX
   • Drag-and-drop, accepts `.json,application/json`.
   • Custom texts: “Click or drop files here” / “JSON credentials only”.
   • Eliminated mandatory `action` warning (`action="#"`).

3. Misc
   • Included IconBolt import.
   • Safeguard toggles reset logic to prevent stale state.

RESULT
Front-end now fully aligns with enhanced AddChannel API:
• Supports Vertex AI multi JSON batch creation.
• Supports new `multi_to_single` flow.
• Clean user feedback with successful file status.
2025-06-16 01:47:41 +08:00
Apple\Apple
aa793088ed fix(playground): send selected group in API payload
Previously, the Playground UI allowed users to pick a group, but the request body sent to `/pg/chat/completions` did not include this information, so the backend always fell back to the user’s default group.

Changes introduced
• web/src/helpers/api.js – added `group: inputs.group` to the `payload` built in `buildApiPayload`.

Outcome
• Selected group is now transmitted to the backend, enabling proper channel routing and pricing logic based on group ratios.
• Resolves the issue where group selection appeared ineffective in the Playground.
2025-06-16 00:58:47 +08:00
CaIon
0089157b83 feat(channel): enhance AddChannel functionality with structured request handling 2025-06-16 00:37:22 +08:00
skynono
b887db474e fix: default request ID to 'SYSTEM' for background tasks 2025-06-09 22:13:21 +08:00
413 changed files with 29752 additions and 14367 deletions

View File

@@ -4,4 +4,5 @@
.vscode
.gitignore
Makefile
docs
docs
.eslintcache

3
.gitignore vendored
View File

@@ -10,4 +10,5 @@ web/dist
.env
one-api
.DS_Store
tiktoken_cache
tiktoken_cache
.eslintcache

View File

@@ -2,6 +2,7 @@ FROM oven/bun:latest AS builder
WORKDIR /build
COPY web/package.json .
COPY web/bun.lock .
RUN bun install
COPY ./web .
COPY ./VERSION .

240
LICENSE
View File

@@ -1,201 +1,103 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
# **New API 许可协议 (Licensing)**
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
1. Definitions.
**核心原则:**
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
---
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
- **场景一:移除品牌和版权信息**
您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
- **场景二:规避 AGPLv3 开源义务**
您基于 New API 进行了修改,并希望:
- 通过网络提供服务SaaS但**不希望**向您的服务用户公开您修改后的源代码。
- 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
- **场景三:企业政策与集成需求**
- 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。
- 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
- **场景四:需要商业支持与保障**
您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
**获取商业许可:**
请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
## **3. 贡献 (Contributions)**
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request都将被视为在 **AGPLv3** 许可证下提供。
- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
## **4. 其他条款 (Other Terms)**
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
---
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
# **New API Licensing**
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
This project uses a **Usage-Based Dual Licensing** model.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
**Core Principles:**
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
---
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
## **1. Open Source License: AGPLv3 For Basic Usage**
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the projects code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
END OF TERMS AND CONDITIONS
## **2. Commercial License For Advanced Scenarios & Closed Source Needs**
APPENDIX: How to apply the Apache License to your work.
You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
- **Scenario 1: Removal of Branding and Copyright**
You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
Copyright [yyyy] [name of copyright owner]
- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**
You have modified New API and wish to:
- Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
- Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
- **Scenario 3: Enterprise Policy & Integration Needs**
- Your organizations policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
- You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
http://www.apache.org/licenses/LICENSE-2.0
- **Scenario 4: Commercial Support and Assurances**
You require commercial assurances not provided by AGPLv3, such as official technical support.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
**Obtaining a Commercial License:**
Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing.
## **3. Contributions**
- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
## **4. Other Terms**
- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).

View File

@@ -189,6 +189,24 @@ If you have any questions, please refer to [Help and Support](https://docs.newap
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🤝 Trusted Partners
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="Peking University" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud" height="58"
/></a>
</p>
<p align="center"><em>No particular order</em></p>
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

View File

@@ -188,6 +188,24 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
- [常见问题](https://docs.newapi.pro/support/faq)
## 🤝 我们信任的合作伙伴
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="北京大学" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud 优刻得" height="58"
/></a>
</p>
<p align="center"><em>排名不分先后</em></p>
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

View File

@@ -63,6 +63,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeXai
case constant.ChannelTypeCoze:
apiType = constant.APITypeCoze
case constant.ChannelTypeJimeng:
apiType = constant.APITypeJimeng
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -193,3 +193,9 @@ const (
ChannelStatusManuallyDisabled = 2 // also don't use 0
ChannelStatusAutoDisabled = 3
)
const (
TopUpStatusPending = "pending"
TopUpStatusSuccess = "success"
TopUpStatusExpired = "expired"
)

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"strings"
"sync"
)
type stringWriter interface {
@@ -52,6 +53,8 @@ type CustomEvent struct {
Id string
Retry uint
Data interface{}
Mutex sync.Mutex
}
func encode(writer io.Writer, event CustomEvent) error {
@@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
}
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
r.Mutex.Lock()
defer r.Mutex.Unlock()
header := w.Header()
header["Content-Type"] = contentType

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/constant"
"strings"
"time"
@@ -32,7 +33,7 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
}
contentType := c.Request.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "application/json") {
err = UnmarshalJson(requestBody, &v)
err = Unmarshal(requestBody, &v)
} else {
// skip for now
// TODO: someday non json request have variant model, we will need to implementation this
@@ -76,3 +77,35 @@ func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]
func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time {
return c.GetTime(string(key))
}
func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) {
if value, ok := c.Get(string(key)); ok {
if v, ok := value.(T); ok {
return v, true
}
}
var t T
return t, false
}
func ApiError(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
}
func ApiErrorMsg(c *gin.Context, msg string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": msg,
})
}
func ApiSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}

34
common/hash.go Normal file
View File

@@ -0,0 +1,34 @@
package common
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
)
func Sha256Raw(data []byte) []byte {
h := sha256.New()
h.Write(data)
return h.Sum(nil)
}
func Sha1Raw(data []byte) []byte {
h := sha1.New()
h.Write(data)
return h.Sum(nil)
}
func Sha1(data []byte) string {
return hex.EncodeToString(Sha1Raw(data))
}
func HmacSha256Raw(message, key []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(message)
return h.Sum(nil)
}
func HmacSha256(message, key string) string {
return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key)))
}

View File

@@ -5,7 +5,7 @@ import (
"encoding/json"
)
func UnmarshalJson(data []byte, v any) error {
func Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
@@ -17,6 +17,6 @@ func DecodeJson(reader *bytes.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
}
func EncodeJson(v any) ([]byte, error) {
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}

View File

@@ -75,6 +75,9 @@ func logHelper(ctx context.Context, level string, msg string) {
writer = gin.DefaultWriter
}
id := ctx.Value(RequestIdKey)
if id == nil {
id = "SYSTEM"
}
now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
logCount++ // we don't need accurate count, so no lock here

View File

@@ -1,15 +1,14 @@
package common
import (
"github.com/gin-gonic/gin"
"strconv"
"github.com/gin-gonic/gin"
)
type PageInfo struct {
Page int `json:"page"` // page num 页码
PageSize int `json:"page_size"` // page size 页大小
StartTimestamp int64 `json:"start_timestamp"` // 秒级
EndTimestamp int64 `json:"end_timestamp"` // 秒级
Page int `json:"page"` // page num 页码
PageSize int `json:"page_size"` // page size 页大小
Total int `json:"total"` // 总条数,后设置
Items any `json:"items"` // 数据,后设置
@@ -39,11 +38,14 @@ func (p *PageInfo) SetItems(items any) {
p.Items = items
}
func GetPageQuery(c *gin.Context) (*PageInfo, error) {
func GetPageQuery(c *gin.Context) *PageInfo {
pageInfo := &PageInfo{}
err := c.BindQuery(pageInfo)
if err != nil {
return nil, err
// 手动获取并处理每个参数
if page, err := strconv.Atoi(c.Query("p")); err == nil {
pageInfo.Page = page
}
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
pageInfo.PageSize = pageSize
}
if pageInfo.Page < 1 {
// 兼容
@@ -56,7 +58,25 @@ func GetPageQuery(c *gin.Context) (*PageInfo, error) {
}
if pageInfo.PageSize == 0 {
pageInfo.PageSize = ItemsPerPage
// 兼容
pageSize, _ := strconv.Atoi(c.Query("ps"))
if pageSize != 0 {
pageInfo.PageSize = pageSize
}
if pageInfo.PageSize == 0 {
pageSize, _ = strconv.Atoi(c.Query("size")) // token page
if pageSize != 0 {
pageInfo.PageSize = pageSize
}
}
if pageInfo.PageSize == 0 {
pageInfo.PageSize = ItemsPerPage
}
}
return pageInfo, nil
if pageInfo.PageSize > 100 {
pageInfo.PageSize = 100
}
return pageInfo
}

View File

@@ -1,9 +1,13 @@
package common
import (
"encoding/base64"
"encoding/json"
"math/rand"
"net/url"
"regexp"
"strconv"
"strings"
"unsafe"
)
@@ -31,16 +35,30 @@ func MapToJsonStr(m map[string]interface{}) string {
return string(bytes)
}
func StrToMap(str string) map[string]interface{} {
func StrToMap(str string) (map[string]interface{}, error) {
m := make(map[string]interface{})
err := json.Unmarshal([]byte(str), &m)
err := Unmarshal([]byte(str), &m)
if err != nil {
return nil
return nil, err
}
return m
return m, nil
}
func IsJsonStr(str string) bool {
func StrToJsonArray(str string) ([]interface{}, error) {
var js []interface{}
err := json.Unmarshal([]byte(str), &js)
if err != nil {
return nil, err
}
return js, nil
}
func IsJsonArray(str string) bool {
var js []interface{}
return json.Unmarshal([]byte(str), &js) == nil
}
func IsJsonObject(str string) bool {
var js map[string]interface{}
return json.Unmarshal([]byte(str), &js) == nil
}
@@ -68,3 +86,107 @@ func StringToByteSlice(s string) []byte {
tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
return *(*[]byte)(unsafe.Pointer(&tmp2))
}
func EncodeBase64(str string) string {
return base64.StdEncoding.EncodeToString([]byte(str))
}
func GetJsonString(data any) string {
if data == nil {
return ""
}
b, _ := json.Marshal(data)
return string(b)
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs in a string
// Example:
// http://example.com -> http://***.com
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
// 192.168.1.1 -> ***.***.***.***
func MaskSensitiveInfo(str string) string {
// Mask URLs
urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
host := u.Host
if host == "" {
return urlStr
}
// Split host by dots
parts := strings.Split(host, ".")
if len(parts) < 2 {
// If less than 2 parts, just mask the whole host
return u.Scheme + "://***" + u.Path
}
// Keep the TLD (Top Level Domain) and mask the rest
var maskedHost string
if len(parts) == 2 {
// example.com -> ***.com
maskedHost = "***." + parts[len(parts)-1]
} else {
// Handle cases like sub.domain.co.uk or api.example.com
// Keep last 2 parts if they look like country code TLD (co.uk, com.cn, etc.)
lastPart := parts[len(parts)-1]
secondLastPart := parts[len(parts)-2]
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
// Likely country code TLD like co.uk, com.cn
maskedHost = "***." + secondLastPart + "." + lastPart
} else {
// Regular TLD like .com, .org
maskedHost = "***." + lastPart
}
}
result := u.Scheme + "://" + maskedHost
// Mask path
if u.Path != "" && u.Path != "/" {
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
maskedPathParts := make([]string, len(pathParts))
for i := range pathParts {
if pathParts[i] != "" {
maskedPathParts[i] = "***"
}
}
if len(maskedPathParts) > 0 {
result += "/" + strings.Join(maskedPathParts, "/")
}
} else if u.Path == "/" {
result += "/"
}
// Mask query parameters
if u.RawQuery != "" {
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
// If can't parse query, just mask the whole query string
result += "?***"
} else {
maskedParams := make([]string, 0, len(values))
for key := range values {
maskedParams = append(maskedParams, key+"=***")
}
if len(maskedParams) > 0 {
result += "?" + strings.Join(maskedParams, "&")
}
}
}
return result
})
// Mask IP addresses
ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
str = ipPattern.ReplaceAllString(str, "***.***.***.***")
return str
}

View File

@@ -30,5 +30,6 @@ const (
APITypeXinference
APITypeXai
APITypeCoze
APITypeJimeng
APITypeDummy // this one is only for count, do not add any channel after this
)

View File

@@ -49,6 +49,7 @@ const (
ChannelTypeCoze = 49
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -106,4 +107,5 @@ var ChannelBaseURLs = []string{
"https://api.coze.cn", //49
"https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52
}

View File

@@ -1,7 +0,0 @@
package constant
var (
ForceFormat = "force_format" // ForceFormat 强制格式化为OpenAI格式
ChanelSettingProxy = "proxy" // Proxy 代理
ChannelSettingThinkingToContent = "thinking_to_content" // ThinkingToContent
)

View File

@@ -17,11 +17,20 @@ const (
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
/* channel related keys */
ContextKeyBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelId ContextKey = "channel_id"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyParamOverride ContextKey = "param_override"
ContextKeyChannelId ContextKey = "channel_id"
ContextKeyChannelName ContextKey = "channel_name"
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
ContextKeyChannelModelMapping ContextKey = "model_mapping"
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key"
/* user related keys */
ContextKeyUserId ContextKey = "id"

View File

@@ -22,6 +22,8 @@ const (
MjActionPan = "PAN"
MjActionSwapFace = "SWAP_FACE"
MjActionUpload = "UPLOAD"
MjActionVideo = "VIDEO"
MjActionEdits = "EDITS"
)
var MidjourneyModel2Action = map[string]string{
@@ -41,4 +43,6 @@ var MidjourneyModel2Action = map[string]string{
"mj_pan": MjActionPan,
"swap_face": MjActionSwapFace,
"mj_upload": MjActionUpload,
"mj_video": MjActionVideo,
"mj_edits": MjActionEdits,
}

View File

@@ -0,0 +1,8 @@
package constant
type MultiKeyMode string
const (
MultiKeyModeRandom MultiKeyMode = "random" // 随机
MultiKeyModePolling MultiKeyMode = "polling" // 轮询
)

View File

@@ -5,8 +5,6 @@ type TaskPlatform string
const (
TaskPlatformSuno TaskPlatform = "suno"
TaskPlatformMidjourney = "mj"
TaskPlatformKling TaskPlatform = "kling"
TaskPlatformJimeng TaskPlatform = "jimeng"
)
const (

View File

@@ -1,16 +0,0 @@
package constant
var (
UserSettingNotifyType = "notify_type" // QuotaWarningType 额度预警类型
UserSettingQuotaWarningThreshold = "quota_warning_threshold" // QuotaWarningThreshold 额度预警阈值
UserSettingWebhookUrl = "webhook_url" // WebhookUrl webhook地址
UserSettingWebhookSecret = "webhook_secret" // WebhookSecret webhook密钥
UserSettingNotificationEmail = "notification_email" // NotificationEmail 通知邮箱地址
UserAcceptUnsetRatioModel = "accept_unset_model_ratio_model" // AcceptUnsetRatioModel 是否接受未设置价格的模型
UserSettingRecordIpLog = "record_ip_log" // 是否记录请求和错误日志IP
)
var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
)

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/shopspring/decimal"
"io"
"net/http"
"one-api/common"
@@ -12,9 +11,12 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/types"
"strconv"
"time"
"github.com/shopspring/decimal"
"github.com/gin-gonic/gin"
)
@@ -409,26 +411,24 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
func UpdateChannelBalance(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, true)
channel, err := model.CacheGetChannel(id)
if err != nil {
common.ApiError(c, err)
return
}
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": "多密钥渠道不支持余额查询",
})
return
}
balance, err := updateChannelBalance(channel)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -436,7 +436,6 @@ func UpdateChannelBalance(c *gin.Context) {
"message": "",
"balance": balance,
})
return
}
func updateAllChannelsBalance() error {
@@ -448,6 +447,9 @@ func updateAllChannelsBalance() error {
if channel.Status != common.ChannelStatusEnabled {
continue
}
if channel.ChannelInfo.IsMultiKey {
continue // skip multi-key channels
}
// TODO: support Azure
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
// continue
@@ -458,7 +460,7 @@ func updateAllChannelsBalance() error {
} else {
// err is nil & balance <= 0 means quota is used up
if balance <= 0 {
service.DisableChannel(channel.Id, channel.Name, "余额不足")
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
}
}
time.Sleep(common.RequestInterval)
@@ -470,10 +472,7 @@ func UpdateAllChannelsBalance(c *gin.Context) {
// TODO: make it async
err := updateAllChannelsBalance()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -17,8 +17,10 @@ import (
"one-api/model"
"one-api/relay"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strconv"
"strings"
"sync"
@@ -29,22 +31,49 @@ import (
"github.com/gin-gonic/gin"
)
func testChannel(channel *model.Channel, testModel string) (err error, openAIErrorWithStatusCode *dto.OpenAIErrorWithStatusCode) {
type testResult struct {
context *gin.Context
localErr error
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
tik := time.Now()
if channel.Type == constant.ChannelTypeMidjourney {
return errors.New("midjourney channel test is not supported"), nil
return testResult{
localErr: errors.New("midjourney channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeMidjourneyPlus {
return errors.New("midjourney plus channel test is not supported"), nil
return testResult{
localErr: errors.New("midjourney plus channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeSunoAPI {
return errors.New("suno channel test is not supported"), nil
return testResult{
localErr: errors.New("suno channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeKling {
return errors.New("kling channel test is not supported"), nil
return testResult{
localErr: errors.New("kling channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeJimeng {
return errors.New("jimeng channel test is not supported"), nil
return testResult{
localErr: errors.New("jimeng channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeVidu {
return testResult{
localErr: errors.New("vidu channel test is not supported"),
newAPIError: nil,
}
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -81,31 +110,49 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
cache, err := model.GetUserCache(1)
if err != nil {
return err, nil
return testResult{
localErr: err,
newAPIError: nil,
}
}
cache.WriteContext(c)
c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
c.Set("channel", channel.Type)
c.Set("base_url", channel.GetBaseURL())
group, _ := model.GetUserGroup(1, false)
c.Set("group", group)
middleware.SetupContextForSelectedChannel(c, channel, testModel)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
if newAPIError != nil {
return testResult{
context: c,
localErr: newAPIError,
newAPIError: newAPIError,
}
}
info := relaycommon.GenRelayInfo(c)
err = helper.ModelMappedHelper(c, info, nil)
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
testModel = info.UpstreamModelName
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
if adaptor == nil {
return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil
return testResult{
context: c,
localErr: fmt.Errorf("invalid api type: %d, adaptor is nil", apiType),
newAPIError: types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType),
}
}
request := buildTestRequest(testModel)
@@ -116,45 +163,91 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens))
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
}
}
adaptor.Init(info)
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
}
}
jsonData, err := json.Marshal(convertedRequest)
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
}
}
requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(requestBody)
resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError),
}
}
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true)
return fmt.Errorf("status code %d: %s", httpResp.StatusCode, err.Error.Message), err
return testResult{
context: c,
localErr: err,
newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError),
}
}
}
usageA, respErr := adaptor.DoResponse(c, httpResp, info)
if respErr != nil {
return fmt.Errorf("%s", respErr.Error.Message), respErr
return testResult{
context: c,
localErr: respErr,
newAPIError: respErr,
}
}
if usageA == nil {
return errors.New("usage is nil"), nil
return testResult{
context: c,
localErr: errors.New("usage is nil"),
newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError),
}
}
usage := usageA.(*dto.Usage)
result := w.Result()
respBody, err := io.ReadAll(result.Body)
if err != nil {
return err, nil
return testResult{
context: c,
localErr: err,
newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
}
}
info.PromptTokens = usage.PromptTokens
@@ -173,10 +266,25 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
consumedTime := float64(milliseconds) / 1000.0
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
quota, "模型测试", 0, quota, int(consumedTime), false, info.UsingGroup, other)
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
ChannelId: channel.Id,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ModelName: info.OriginModelName,
TokenName: "模型测试",
Quota: quota,
Content: "模型测试",
UseTimeSeconds: int(consumedTime),
IsStream: false,
Group: info.UsingGroup,
Other: other,
})
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return nil, nil
return testResult{
context: c,
localErr: nil,
newAPIError: nil,
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
@@ -191,7 +299,7 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []string{"hello world"}
testRequest.Input = []any{"hello world"} // 修改为any因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
}
// 并非Embedding 模型
@@ -219,31 +327,38 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
func TestChannel(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(channelId, true)
channel, err := model.CacheGetChannel(channelId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
//defer func() {
// if channel.ChannelInfo.IsMultiKey {
// go func() { _ = channel.SaveChannelInfo() }()
// }
//}()
testModel := c.Query("model")
tik := time.Now()
err, _ = testChannel(channel, testModel)
result := testChannel(channel, testModel)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.localErr.Error(),
"time": 0.0,
})
return
}
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if err != nil {
if result.newAPIError != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": result.newAPIError.Error(),
"time": consumedTime,
})
return
@@ -268,9 +383,9 @@ func testAllChannels(notify bool) error {
}
testAllChannelsRunning = true
testAllChannelsLock.Unlock()
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
return err
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
if getChannelErr != nil {
return getChannelErr
}
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
if disableThreshold == 0 {
@@ -287,32 +402,34 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
err, openaiWithStatusErr := testChannel(channel, "")
result := testChannel(channel, "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
shouldBanChannel := false
newAPIError := result.newAPIError
// request error disables the channel
if openaiWithStatusErr != nil {
oaiErr := openaiWithStatusErr.Error
err = errors.New(fmt.Sprintf("type %s, httpCode %d, code %v, message %s", oaiErr.Type, openaiWithStatusErr.StatusCode, oaiErr.Code, oaiErr.Message))
shouldBanChannel = service.ShouldDisableChannel(channel.Type, openaiWithStatusErr)
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
}
if milliseconds > disableThreshold {
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
shouldBanChannel = true
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
}
// disable channel
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
service.DisableChannel(channel.Id, channel.Name, err.Error())
go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
}
// enable channel
if !isChannelEnabled && service.ShouldEnableChannel(err, openaiWithStatusErr, channel.Status) {
service.EnableChannel(channel.Id, channel.Name)
if !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
}
channel.UpdateResponseTime(milliseconds)
@@ -329,10 +446,7 @@ func testAllChannels(notify bool) error {
func TestAllChannels(c *gin.Context) {
err := testAllChannels(true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -343,6 +457,10 @@ func TestAllChannels(c *gin.Context) {
}
func AutomaticallyTestChannels(frequency int) {
if frequency <= 0 {
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
return
}
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("testing all channels")

View File

@@ -53,14 +53,7 @@ func parseStatusFilter(statusParam string) int {
}
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
@@ -79,7 +72,7 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
@@ -126,7 +119,7 @@ func GetAllChannels(c *gin.Context) {
order = "id desc"
}
err := baseQuery.Order(order).Limit(pageSize).Offset((p - 1) * pageSize).Omit("key").Find(&channelData).Error
err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
@@ -148,17 +141,12 @@ func GetAllChannels(c *gin.Context) {
for _, r := range results {
typeCounts[r.Type] = r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
"type_counts": typeCounts,
},
common.ApiSuccess(c, gin.H{
"items": channelData,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"type_counts": typeCounts,
})
return
}
@@ -166,19 +154,13 @@ func GetAllChannels(c *gin.Context) {
func FetchUpstreamModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -195,10 +177,7 @@ func FetchUpstreamModels(c *gin.Context) {
}
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -228,18 +207,18 @@ func FetchUpstreamModels(c *gin.Context) {
}
func FixChannelsAbilities(c *gin.Context) {
count, err := model.FixAbility()
success, fails, err := model.FixAbility()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
"data": gin.H{
"success": success,
"fails": fails,
},
})
}
@@ -355,18 +334,12 @@ func SearchChannels(c *gin.Context) {
func GetChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -377,66 +350,167 @@ func GetChannel(c *gin.Context) {
return
}
func AddChannel(c *gin.Context) {
channel := model.Channel{}
err := c.ShouldBindJSON(&channel)
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
// 如果是添加操作,检查 channel 和 key 是否为空
if isAdd {
if channel == nil || channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}
// 检查模型名称长度是否超过 255
for _, m := range channel.GetModels() {
if len(m) > 255 {
return fmt.Errorf("模型名称过长: %s", m)
}
}
}
// VertexAI 特殊校验
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
return fmt.Errorf("部署地区不能为空")
}
regionMap, err := common.StrToMap(channel.Other)
if err != nil {
return fmt.Errorf("部署地区必须是标准的Json格式例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}")
}
if regionMap["default"] == nil {
return fmt.Errorf("部署地区必须包含default字段")
}
}
return nil
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
Channel *model.Channel `json:"channel"`
}
func getVertexArrayKeys(keys string) ([]string, error) {
if keys == "" {
return nil, nil
}
var keyArray []interface{}
err := common.Unmarshal([]byte(keys), &keyArray)
if err != nil {
return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式例如[{key1}, {key2}...],请检查输入: %w", err)
}
cleanKeys := make([]string, 0, len(keyArray))
for _, key := range keyArray {
var keyStr string
switch v := key.(type) {
case string:
keyStr = strings.TrimSpace(v)
default:
bytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err)
}
keyStr = string(bytes)
}
if keyStr != "" {
cleanKeys = append(cleanKeys, keyStr)
}
}
if len(cleanKeys) == 0 {
return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空")
}
return cleanKeys, nil
}
func AddChannel(c *gin.Context) {
addChannelRequest := AddChannelRequest{}
err := c.ShouldBindJSON(&addChannelRequest)
if err != nil {
common.ApiError(c, err)
return
}
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channel.CreatedTime = common.GetTimestamp()
keys := strings.Split(channel.Key, "\n")
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区不能为空",
})
return
} else {
if common.IsJsonStr(channel.Other) {
// must have default
regionMap := common.StrToMap(channel.Other)
if regionMap["default"] == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须包含default字段",
})
return
}
addChannelRequest.Channel.CreatedTime = common.GetTimestamp()
keys := make([]string, 0)
switch addChannelRequest.Mode {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
addChannelRequest.Channel.Key = strings.Join(array, "\n")
} else {
cleanKeys := make([]string, 0)
for _, key := range strings.Split(addChannelRequest.Channel.Key, "\n") {
if key == "" {
continue
}
key = strings.TrimSpace(key)
cleanKeys = append(cleanKeys, key)
}
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys)
addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n")
}
keys = []string{channel.Key}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
keys = strings.Split(addChannelRequest.Channel.Key, "\n")
}
case "single":
keys = []string{addChannelRequest.Channel.Key}
default:
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不支持的添加模式",
})
return
}
channels := make([]model.Channel, 0, len(keys))
for _, key := range keys {
if key == "" {
continue
}
localChannel := channel
localChannel := addChannelRequest.Channel
localChannel.Key = key
// Validate the length of the model name
models := strings.Split(localChannel.Models, ",")
for _, model := range models {
if len(model) > 255 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("模型名称过长: %s", model),
})
return
}
}
channels = append(channels, localChannel)
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -451,12 +525,10 @@ func DeleteChannel(c *gin.Context) {
channel := model.Channel{Id: id}
err := channel.Delete()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -467,12 +539,10 @@ func DeleteChannel(c *gin.Context) {
func DeleteDisabledChannel(c *gin.Context) {
rows, err := model.DeleteDisabledChannel()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -503,12 +573,10 @@ func DisableTagChannels(c *gin.Context) {
}
err = model.DisableChannelByTag(channelTag.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -528,12 +596,10 @@ func EnableTagChannels(c *gin.Context) {
}
err = model.EnableChannelByTag(channelTag.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -560,12 +626,10 @@ func EditTagChannels(c *gin.Context) {
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -590,12 +654,10 @@ func DeleteChannelBatch(c *gin.Context) {
}
err = model.BatchDeleteChannels(channelBatch.Ids)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -604,9 +666,29 @@ func DeleteChannelBatch(c *gin.Context) {
return
}
type PatchChannel struct {
model.Channel
MultiKeyMode *string `json:"multi_key_mode"`
}
func UpdateChannel(c *gin.Context) {
channel := model.Channel{}
channel := PatchChannel{}
err := c.ShouldBindJSON(&channel)
if err != nil {
common.ApiError(c, err)
return
}
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -614,35 +696,20 @@ func UpdateChannel(c *gin.Context) {
})
return
}
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区不能为空",
})
return
} else {
if common.IsJsonStr(channel.Other) {
// must have default
regionMap := common.StrToMap(channel.Other)
if regionMap["default"] == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须包含default字段",
})
return
}
}
}
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
channel.ChannelInfo = originChannel.ChannelInfo
// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
}
err = channel.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
channel.Key = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -745,12 +812,10 @@ func BatchSetChannelTag(c *gin.Context) {
}
err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -799,3 +864,53 @@ func GetTagModels(c *gin.Context) {
})
return
}
// CopyChannel handles cloning an existing channel with its key.
// POST /api/channel/copy/:id
// Optional query params:
//
// suffix - string appended to the original name (default "_复制")
// reset_balance - bool, when true will reset balance & used_quota to 0 (default true)
func CopyChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"})
return
}
suffix := c.DefaultQuery("suffix", "_复制")
resetBalance := true
if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" {
if v, err := strconv.ParseBool(rbStr); err == nil {
resetBalance = v
}
}
// fetch original channel with key
origin, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
// clone channel
clone := *origin // shallow copy is sufficient as we will overwrite primitives
clone.Id = 0 // let DB auto-generate
clone.CreatedTime = common.GetTimestamp()
clone.Name = origin.Name + suffix
clone.TestTime = 0
clone.ResponseTime = 0
if resetBalance {
clone.Balance = 0
clone.UsedQuota = 0
}
// insert
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
model.InitChannelCache()
// success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
}

View File

@@ -5,13 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type GitHubOAuthResponse struct {
@@ -103,10 +104,7 @@ func GitHubOAuth(c *gin.Context) {
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -185,10 +183,7 @@ func GitHubBind(c *gin.Context) {
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -207,19 +202,13 @@ func GitHubBind(c *gin.Context) {
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.GitHubId = githubUser.Login
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -239,10 +228,7 @@ func GenerateOAuthCode(c *gin.Context) {
session.Set("oauth_state", state)
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -38,10 +38,7 @@ func LinuxDoBind(c *gin.Context) {
code := c.Query("code")
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -63,20 +60,14 @@ func LinuxDoBind(c *gin.Context) {
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -202,10 +193,7 @@ func LinuxdoOAuth(c *gin.Context) {
code := c.Query("code")
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}

View File

@@ -10,14 +10,7 @@ import (
)
func GetAllLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
@@ -26,38 +19,19 @@ func GetAllLogs(c *gin.Context) {
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, (p-1)*pageSize, pageSize, channel, group)
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": map[string]any{
"items": logs,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
func GetUserLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
if pageSize > 100 {
pageSize = 100
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
@@ -65,24 +39,14 @@ func GetUserLogs(c *gin.Context) {
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
group := c.Query("group")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, (p-1)*pageSize, pageSize, group)
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": map[string]any{
"items": logs,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
@@ -90,10 +54,7 @@ func SearchAllLogs(c *gin.Context) {
keyword := c.Query("keyword")
logs, err := model.SearchAllLogs(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -109,10 +70,7 @@ func SearchUserLogs(c *gin.Context) {
userId := c.GetInt("id")
logs, err := model.SearchUserLogs(userId, keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -198,10 +156,7 @@ func DeleteHistoryLogs(c *gin.Context) {
}
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -13,8 +12,9 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
func UpdateMidjourneyTaskBulk() {
@@ -213,14 +213,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
}
func GetAllMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
// 解析其他查询参数
queryParams := model.TaskQueryParams{
@@ -230,7 +223,7 @@ func GetAllMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllTasks(queryParams)
if setting.MjForwardUrlEnabled {
@@ -239,27 +232,13 @@ func GetAllMidjourney(c *gin.Context) {
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
func GetUserMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
@@ -269,7 +248,7 @@ func GetUserMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserTask(userId, queryParams)
if setting.MjForwardUrlEnabled {
@@ -278,14 +257,7 @@ func GetUserMidjourney(c *gin.Context) {
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

View File

@@ -57,7 +57,9 @@ func GetStatus(c *gin.Context) {
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
"min_topup": setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
@@ -71,12 +73,14 @@ func GetStatus(c *gin.Context) {
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"pay_methods": setting.PayMethods,
"usd_exchange_rate": setting.USDExchangeRate,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
@@ -214,10 +218,7 @@ func SendEmailVerification(c *gin.Context) {
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -253,10 +254,7 @@ func SendPasswordResetEmail(c *gin.Context) {
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -291,10 +289,7 @@ func ResetPassword(c *gin.Context) {
password := common.GenerateVerificationCode(12)
err = model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
common.DeleteKey(req.Email, common.PasswordResetPurpose)

View File

@@ -130,7 +130,7 @@ func ListModels(c *gin.Context) {
}
} else {
userId := c.GetInt("id")
userGroup, err := model.GetUserGroup(userId, true)
userGroup, err := model.GetUserGroup(userId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -139,7 +139,7 @@ func ListModels(c *gin.Context) {
return
}
group := userGroup
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
if tokenGroup != "" {
group = tokenGroup
}

View File

@@ -126,10 +126,7 @@ func OidcAuth(c *gin.Context) {
code := c.Query("code")
oidcUser, err := getOidcUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -195,10 +192,7 @@ func OidcBind(c *gin.Context) {
code := c.Query("code")
oidcUser, err := getOidcUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -217,19 +211,13 @@ func OidcBind(c *gin.Context) {
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.OidcId = oidcUser.OpenID
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -160,10 +160,7 @@ func UpdateOption(c *gin.Context) {
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -3,45 +3,44 @@ package controller
import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/types"
"time"
"github.com/gin-gonic/gin"
)
func Playground(c *gin.Context) {
var openaiErr *dto.OpenAIErrorWithStatusCode
var newAPIError *types.NewAPIError
defer func() {
if openaiErr != nil {
c.JSON(openaiErr.StatusCode, gin.H{
"error": openaiErr.Error,
if newAPIError != nil {
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
}()
useAccessToken := c.GetBool("use_access_token")
if useAccessToken {
openaiErr = service.OpenAIErrorWrapperLocal(errors.New("暂不支持使用 access token"), "access_token_not_supported", http.StatusBadRequest)
newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
playgroundRequest := &dto.PlayGroundRequest{}
err := common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
openaiErr = service.OpenAIErrorWrapperLocal(err, "unmarshal_request_failed", http.StatusBadRequest)
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
if playgroundRequest.Model == "" {
openaiErr = service.OpenAIErrorWrapperLocal(errors.New("请选择模型"), "model_required", http.StatusBadRequest)
newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
c.Set("original_model", playgroundRequest.Model)
@@ -52,28 +51,34 @@ func Playground(c *gin.Context) {
group = userGroup
} else {
if !setting.GroupInUserUsableGroups(group) && group != userGroup {
openaiErr = service.OpenAIErrorWrapperLocal(errors.New("无权访问该分组"), "group_not_allowed", http.StatusForbidden)
newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
c.Set("group", group)
}
c.Set("token_name", "playground-"+group)
channel, finalGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, playgroundRequest.Model, 0)
if err != nil {
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", finalGroup, playgroundRequest.Model)
openaiErr = service.OpenAIErrorWrapperLocal(errors.New(message), "get_playground_channel_failed", http.StatusInternalServerError)
return
}
middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
userId := c.GetInt("id")
// Write user context to ensure acceptUnsetRatio is available
userId := c.GetInt("id")
userCache, err := model.GetUserCache(userId)
if err != nil {
openaiErr = service.OpenAIErrorWrapperLocal(err, "get_user_cache_failed", http.StatusInternalServerError)
newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
return
}
userCache.WriteContext(c)
tempToken := &model.Token{
UserId: userId,
Name: fmt.Sprintf("playground-%s", group),
Group: group,
}
_ = middleware.SetupContextForToken(c, tempToken)
_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
if newAPIError != nil {
return
}
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
Relay(c)
}

View File

@@ -1,91 +1,52 @@
package controller
import (
"errors"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"errors"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
func GetAllRedemptions(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
redemptions, total, err := model.GetAllRedemptions((p-1)*pageSize, pageSize)
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": redemptions,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func SearchRedemptions(c *gin.Context) {
keyword := c.Query("keyword")
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
redemptions, total, err := model.SearchRedemptions(keyword, (p-1)*pageSize, pageSize)
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": redemptions,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func GetRedemption(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
redemption, err := model.GetRedemptionById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -100,13 +61,10 @@ func AddRedemption(c *gin.Context) {
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "兑换码名称长度必须在1-20之间",
@@ -165,10 +123,7 @@ func DeleteRedemption(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
err := model.DeleteRedemptionById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -183,18 +138,12 @@ func UpdateRedemption(c *gin.Context) {
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if statusOnly == "" {
@@ -212,10 +161,7 @@ func UpdateRedemption(c *gin.Context) {
}
err = cleanRedemption.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -229,16 +175,13 @@ func UpdateRedemption(c *gin.Context) {
func DeleteInvalidRedemption(c *gin.Context) {
rows, err := model.DeleteInvalidRedemptions()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": rows,
"data": rows,
})
return
}

View File

@@ -17,14 +17,15 @@ import (
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode {
var err *dto.OpenAIErrorWithStatusCode
func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
var err *types.NewAPIError
switch relayMode {
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
err = relay.ImageHelper(c)
@@ -55,14 +56,13 @@ func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
userGroup := c.GetString("group")
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
other["error_type"] = err.Error.Type
other["error_code"] = err.Error.Code
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error.Message, tokenId, 0, false, userGroup, other)
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
}
return err
@@ -73,25 +73,25 @@ func Relay(c *gin.Context) {
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
originalModel := c.GetString("original_model")
var openaiErr *dto.OpenAIErrorWithStatusCode
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
openaiErr = service.OpenAIErrorWrapperLocal(err, "get_channel_failed", http.StatusInternalServerError)
newAPIError = err
break
}
openaiErr = relayRequest(c, relayMode, channel)
newAPIError = relayRequest(c, relayMode, channel)
if openaiErr == nil {
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), openaiErr)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, openaiErr, common.RetryTimes-i) {
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
@@ -101,14 +101,14 @@ func Relay(c *gin.Context) {
common.LogInfo(c, retryLogStr)
}
if openaiErr != nil {
if openaiErr.StatusCode == http.StatusTooManyRequests {
common.LogError(c, fmt.Sprintf("origin 429 error: %s", openaiErr.Error.Message))
openaiErr.Error.Message = "当前分组上游负载已饱和,请稍后再试"
}
openaiErr.Error.Message = common.MessageWithRequestId(openaiErr.Error.Message, requestId)
c.JSON(openaiErr.StatusCode, gin.H{
"error": openaiErr.Error,
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
}
@@ -127,8 +127,7 @@ func WssRelay(c *gin.Context) {
defer ws.Close()
if err != nil {
openaiErr := service.OpenAIErrorWrapper(err, "get_channel_failed", http.StatusInternalServerError)
helper.WssError(c, ws, openaiErr.Error)
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
return
}
@@ -137,25 +136,25 @@ func WssRelay(c *gin.Context) {
group := c.GetString("group")
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
originalModel := c.GetString("original_model")
var openaiErr *dto.OpenAIErrorWithStatusCode
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
openaiErr = service.OpenAIErrorWrapperLocal(err, "get_channel_failed", http.StatusInternalServerError)
newAPIError = err
break
}
openaiErr = wssRequest(c, ws, relayMode, channel)
newAPIError = wssRequest(c, ws, relayMode, channel)
if openaiErr == nil {
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), openaiErr)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, openaiErr, common.RetryTimes-i) {
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
@@ -165,12 +164,12 @@ func WssRelay(c *gin.Context) {
common.LogInfo(c, retryLogStr)
}
if openaiErr != nil {
if openaiErr.StatusCode == http.StatusTooManyRequests {
openaiErr.Error.Message = "当前分组上游负载已饱和,请稍后再试"
}
openaiErr.Error.Message = common.MessageWithRequestId(openaiErr.Error.Message, requestId)
helper.WssError(c, ws, openaiErr.Error)
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
helper.WssError(c, ws, newAPIError.ToOpenAIError())
}
}
@@ -179,27 +178,25 @@ func RelayClaude(c *gin.Context) {
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
originalModel := c.GetString("original_model")
var claudeErr *dto.ClaudeErrorWithStatusCode
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
claudeErr = service.ClaudeErrorWrapperLocal(err, "get_channel_failed", http.StatusInternalServerError)
newAPIError = err
break
}
claudeErr = claudeRequest(c, channel)
newAPIError = claudeRequest(c, channel)
if claudeErr == nil {
if newAPIError == nil {
return // 成功处理请求,直接返回
}
openaiErr := service.ClaudeErrorToOpenAIError(claudeErr)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), openaiErr)
if !shouldRetry(c, openaiErr, common.RetryTimes-i) {
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
@@ -209,30 +206,30 @@ func RelayClaude(c *gin.Context) {
common.LogInfo(c, retryLogStr)
}
if claudeErr != nil {
claudeErr.Error.Message = common.MessageWithRequestId(claudeErr.Error.Message, requestId)
c.JSON(claudeErr.StatusCode, gin.H{
if newAPIError != nil {
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": claudeErr.Error,
"error": newAPIError.ToClaudeError(),
})
}
}
func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *dto.OpenAIErrorWithStatusCode {
func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relayHandler(c, relayMode)
}
func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *dto.OpenAIErrorWithStatusCode {
func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relay.WssHelper(c, ws)
}
func claudeRequest(c *gin.Context, channel *model.Channel) *dto.ClaudeErrorWithStatusCode {
func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
@@ -245,7 +242,7 @@ func addUsedChannel(c *gin.Context, channelId int) {
c.Set("use_channel", useChannel)
}
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, error) {
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) {
if retryCount == 0 {
autoBan := c.GetBool("auto_ban")
autoBanInt := 1
@@ -259,19 +256,28 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt,
}, nil
}
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, errors.New(fmt.Sprintf("获取重试渠道失败: %s", err.Error()))
return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败retry: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在数据库一致性已被破坏retry", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
return nil, newAPIError
}
middleware.SetupContextForSelectedChannel(c, channel, originalModel)
return channel, nil
}
func shouldRetry(c *gin.Context, openaiErr *dto.OpenAIErrorWithStatusCode, retryTimes int) bool {
func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool {
if openaiErr == nil {
return false
}
if openaiErr.LocalError {
if types.IsChannelError(openaiErr) {
return true
}
if types.IsSkipRetryError(openaiErr) {
return false
}
if retryTimes <= 0 {
@@ -310,12 +316,12 @@ func shouldRetry(c *gin.Context, openaiErr *dto.OpenAIErrorWithStatusCode, retry
return true
}
func processChannelError(c *gin.Context, channelId int, channelType int, channelName string, autoBan bool, err *dto.OpenAIErrorWithStatusCode) {
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelId, err.StatusCode, err.Error.Message))
if service.ShouldDisableChannel(channelType, err) && autoBan {
service.DisableChannel(channelId, channelName, err.Error.Message)
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
service.DisableChannel(channelError, err.Error())
}
}
@@ -388,9 +394,10 @@ func RelayTask(c *gin.Context) {
retryTimes = 0
}
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error()))
channel, newAPIError := getChannel(c, group, originalModel, i)
if newAPIError != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
channelId = channel.Id
@@ -398,9 +405,9 @@ func RelayTask(c *gin.Context) {
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
middleware.SetupContextForSelectedChannel(c, channel, originalModel)
//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
requestBody, err := common.GetRequestBody(c)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayMode)
}
@@ -420,7 +427,7 @@ func RelayTask(c *gin.Context) {
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
var err *dto.TaskError
switch relayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID:
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
err = relay.RelayTaskFetch(c, relayMode)
default:
err = relay.RelayTaskSubmit(c, relayMode)

136
controller/swag_video.go Normal file
View File

@@ -0,0 +1,136 @@
package controller
import (
"github.com/gin-gonic/gin"
)
// VideoGenerations
// @Summary 生成视频
// @Description 调用视频生成接口生成视频
// @Description 支持多种视频生成服务:
// @Description - 可灵AI (Kling): https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo
// @Description - 即梦 (Jimeng): https://www.volcengine.com/docs/85621/1538636
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body dto.VideoRequest true "视频生成请求参数"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /v1/video/generations [post]
func VideoGenerations(c *gin.Context) {
}
// VideoGenerationsTaskId
// @Summary 查询视频
// @Description 根据任务ID查询视频生成任务的状态和结果
// @Tags Video
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param task_id path string true "Task ID"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /v1/video/generations/{task_id} [get]
func VideoGenerationsTaskId(c *gin.Context) {
}
// KlingText2VideoGenerations
// @Summary 可灵文生视频
// @Description 调用可灵AI文生视频接口生成视频内容
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body KlingText2VideoRequest true "视频生成请求参数"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /kling/v1/videos/text2video [post]
func KlingText2VideoGenerations(c *gin.Context) {
}
type KlingText2VideoRequest struct {
ModelName string `json:"model_name,omitempty" example:"kling-v1"`
Prompt string `json:"prompt" binding:"required" example:"A cat playing piano in the garden"`
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
Mode string `json:"mode,omitempty" example:"std"`
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
Duration string `json:"duration,omitempty" example:"5"`
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-001"`
}
type KlingCameraControl struct {
Type string `json:"type,omitempty" example:"simple"`
Config *KlingCameraConfig `json:"config,omitempty"`
}
type KlingCameraConfig struct {
Horizontal float64 `json:"horizontal,omitempty" example:"2.5"`
Vertical float64 `json:"vertical,omitempty" example:"0"`
Pan float64 `json:"pan,omitempty" example:"0"`
Tilt float64 `json:"tilt,omitempty" example:"0"`
Roll float64 `json:"roll,omitempty" example:"0"`
Zoom float64 `json:"zoom,omitempty" example:"0"`
}
// KlingImage2VideoGenerations
// @Summary 可灵官方-图生视频
// @Description 调用可灵AI图生视频接口生成视频内容
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body KlingImage2VideoRequest true "图生视频请求参数"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /kling/v1/videos/image2video [post]
func KlingImage2VideoGenerations(c *gin.Context) {
}
type KlingImage2VideoRequest struct {
ModelName string `json:"model_name,omitempty" example:"kling-v2-master"`
Image string `json:"image" binding:"required" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"`
Prompt string `json:"prompt,omitempty" example:"A cat playing piano in the garden"`
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
Mode string `json:"mode,omitempty" example:"std"`
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
Duration string `json:"duration,omitempty" example:"5"`
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-002"`
}
// KlingImage2videoTaskId godoc
// @Summary 可灵任务查询--图生视频
// @Description Query the status and result of a Kling video generation task by task ID
// @Tags Origin
// @Accept json
// @Produce json
// @Param task_id path string true "Task ID"
// @Router /kling/v1/videos/image2video/{task_id} [get]
func KlingImage2videoTaskId(c *gin.Context) {}
// KlingText2videoTaskId godoc
// @Summary 可灵任务查询--文生视频
// @Description Query the status and result of a Kling text-to-video generation task by task ID
// @Tags Origin
// @Accept json
// @Produce json
// @Param task_id path string true "Task ID"
// @Router /kling/v1/videos/text2video/{task_id} [get]
func KlingText2videoTaskId(c *gin.Context) {}

View File

@@ -5,8 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"io"
"net/http"
"one-api/common"
@@ -17,6 +15,9 @@ import (
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func UpdateTaskBulk() {
@@ -74,10 +75,10 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
case constant.TaskPlatformSuno:
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
case constant.TaskPlatformKling, constant.TaskPlatformJimeng:
_ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM)
default:
common.SysLog("未知平台")
if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err))
}
}
}
@@ -225,14 +226,7 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
}
func GetAllTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
@@ -247,30 +241,15 @@ func GetAllTask(c *gin.Context) {
ChannelID: c.Query("channel_id"),
}
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllTasks(queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
func GetUserTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
@@ -286,17 +265,9 @@ func GetUserTask(c *gin.Context) {
EndTimestamp: endTimestamp,
}
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllUserTask(userId, queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

View File

@@ -2,13 +2,16 @@ package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"time"
)
@@ -77,13 +80,21 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
taskResult, err := adaptor.ParseTaskResult(responseBody)
if err != nil {
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
taskResult.Url = t.FailReason
taskResult.Progress = t.Progress
taskResult.Reason = t.FailReason
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
} else {
task.Data = responseBody
}
//if taskResult.Code != 0 {
// return fmt.Errorf("video task fetch failed for task %s", taskId)
//}
now := time.Now().Unix()
if taskResult.Status == "" {
@@ -128,8 +139,6 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if taskResult.Progress != "" {
task.Progress = taskResult.Progress
}
task.Data = responseBody
if err := task.Update(); err != nil {
common.SysError("UpdateVideoTask task error: " + err.Error())
}

View File

@@ -1,46 +1,26 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
p, _ := strconv.Atoi(c.Query("p"))
size, _ := strconv.Atoi(c.Query("size"))
if p < 1 {
p = 1
}
if size <= 0 {
size = common.ItemsPerPage
} else if size > 100 {
size = 100
}
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
pageInfo := common.GetPageQuery(c)
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// Get total count for pagination
total, _ := model.CountUserTokens(userId)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": tokens,
"total": total,
"page": p,
"page_size": size,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
common.ApiSuccess(c, pageInfo)
return
}
@@ -50,10 +30,7 @@ func SearchTokens(c *gin.Context) {
token := c.Query("token")
tokens, err := model.SearchUserTokens(userId, keyword, token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -68,18 +45,12 @@ func GetToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -95,10 +66,7 @@ func GetTokenStatus(c *gin.Context) {
userId := c.GetInt("id")
token, err := model.GetTokenByIds(tokenId, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
expiredAt := token.ExpiredTime
@@ -118,10 +86,7 @@ func AddToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(token.Name) > 30 {
@@ -156,10 +121,7 @@ func AddToken(c *gin.Context) {
}
err = cleanToken.Insert()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -174,10 +136,7 @@ func DeleteToken(c *gin.Context) {
userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -193,10 +152,7 @@ func UpdateToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(token.Name) > 30 {
@@ -208,10 +164,7 @@ func UpdateToken(c *gin.Context) {
}
cleanToken, err := model.GetTokenByIds(token.Id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if token.Status == common.TokenStatusEnabled {
@@ -245,10 +198,7 @@ func UpdateToken(c *gin.Context) {
}
err = cleanToken.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -275,10 +225,7 @@ func DeleteTokenBatch(c *gin.Context) {
userId := c.GetInt("id")
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

275
controller/topup_stripe.go Normal file
View File

@@ -0,0 +1,275 @@
package controller
import (
"fmt"
"io"
"log"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
"github.com/stripe/stripe-go/v81/webhook"
"github.com/thanhpk/randstr"
)
const (
PaymentMethodStripe = "stripe"
)
var stripeAdaptor = &StripeAdaptor{}
type StripePayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}
type StripeAdaptor struct {
}
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getStripePayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
if req.PaymentMethod != PaymentMethodStripe {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
return
}
if req.Amount > 10000 {
c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
return
}
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "ref_" + common.Sha1([]byte(reference))
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount)
if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
topUp := &model.TopUp{
UserId: id,
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{
"message": "success",
"data": gin.H{
"pay_link": payLink,
},
})
}
func RequestStripeAmount(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestAmount(c, &req)
}
func RequestStripePay(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestPay(c, &req)
}
func StripeWebhook(c *gin.Context) {
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
c.AbortWithStatus(http.StatusServiceUnavailable)
return
}
signature := c.GetHeader("Stripe-Signature")
endpointSecret := setting.StripeWebhookSecret
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
log.Printf("Stripe Webhook验签失败: %v\n", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted:
sessionCompleted(event)
case stripe.EventTypeCheckoutSessionExpired:
sessionExpired(event)
default:
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
}
c.Status(http.StatusOK)
}
func sessionCompleted(event stripe.Event) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "complete" != status {
log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
return
}
err := model.Recharge(referenceId, customerId)
if err != nil {
log.Println(err.Error(), referenceId)
return
}
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
currency := strings.ToUpper(event.GetObjectValue("currency"))
log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
}
func sessionExpired(event stripe.Event) {
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "expired" != status {
log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
return
}
if len(referenceId) == 0 {
log.Println("未提供支付单号")
return
}
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Println("充值订单不存在", referenceId)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Println("充值订单状态错误", referenceId)
}
topUp.Status = common.TopUpStatusExpired
err := topUp.Update()
if err != nil {
log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
return
}
log.Println("充值订单已过期", referenceId)
}
func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) {
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
return "", fmt.Errorf("无效的Stripe API密钥")
}
stripe.Key = setting.StripeApiSecret
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(setting.ServerAddress + "/log"),
CancelURL: stripe.String(setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),
Quantity: stripe.Int64(amount),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
}
if "" == customerId {
if "" != email {
params.CustomerEmail = stripe.String(email)
}
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
} else {
params.Customer = stripe.String(customerId)
}
result, err := session.New(params)
if err != nil {
return "", err
}
return result.URL, nil
}
func GetChargedAmount(count float64, user model.User) float64 {
topUpGroupRatio := common.GetTopupGroupRatio(user.Group)
if topUpGroupRatio == 0 {
topUpGroupRatio = 1
}
return count * topUpGroupRatio
}
func getStripePayMoney(amount float64, group string) float64 {
if !common.DisplayInCurrencyEnabled {
amount = amount / common.QuotaPerUnit
}
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
return payMoney
}
func getStripeMinTopup() int64 {
minTopup := setting.StripeMinTopUp
if !common.DisplayInCurrencyEnabled {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
}

View File

@@ -1,10 +1,12 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func GetAllQuotaDates(c *gin.Context) {
@@ -13,10 +15,7 @@ func GetAllQuotaDates(c *gin.Context) {
username := c.Query("username")
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -41,10 +40,7 @@ func GetUserQuotaDates(c *gin.Context) {
}
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting"
"strconv"
@@ -187,10 +188,7 @@ func Register(c *gin.Context) {
cleanUser.Email = user.Email
}
if err := cleanUser.Insert(inviterId); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -246,81 +244,45 @@ func Register(c *gin.Context) {
}
func GetAllUsers(c *gin.Context) {
pageInfo, err := common.GetPageQuery(c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "parse page query failed",
})
return
}
pageInfo := common.GetPageQuery(c)
users, total, err := model.GetAllUsers(pageInfo)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": pageInfo,
})
common.ApiSuccess(c, pageInfo)
return
}
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
startIdx := (p - 1) * pageSize
users, total, err := model.SearchUsers(keyword, group, startIdx, pageSize)
pageInfo := common.GetPageQuery(c)
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": users,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
common.ApiSuccess(c, pageInfo)
return
}
func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -343,10 +305,7 @@ func GenerateAccessToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// get rand int 28-32
@@ -371,10 +330,7 @@ func GenerateAccessToken(c *gin.Context) {
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -394,18 +350,12 @@ func TransferAffQuota(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
tran := TransferAffQuotaRequest{}
if err := c.ShouldBindJSON(&tran); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
err = user.TransferAffQuotaToQuota(tran.Quota)
@@ -426,10 +376,7 @@ func GetAffCode(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if user.AffCode == "" {
@@ -454,10 +401,7 @@ func GetSelf(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
@@ -478,10 +422,7 @@ func GetUserModels(c *gin.Context) {
}
user, err := model.GetUserCache(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
groups := setting.GetUserUsableGroups(user.Group)
@@ -523,10 +464,7 @@ func UpdateUser(c *gin.Context) {
}
originUser, err := model.GetUserById(updatedUser.Id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -549,10 +487,7 @@ func UpdateUser(c *gin.Context) {
}
updatePassword := updatedUser.Password != ""
if err := updatedUser.Edit(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if originUser.Quota != updatedUser.Quota {
@@ -598,17 +533,11 @@ func UpdateSelf(c *gin.Context) {
}
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -639,18 +568,12 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int
func DeleteUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
originUser, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -685,10 +608,7 @@ func DeleteSelf(c *gin.Context) {
err := model.DeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -734,10 +654,7 @@ func CreateUser(c *gin.Context) {
DisplayName: user.DisplayName,
}
if err := cleanUser.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -847,10 +764,7 @@ func ManageUser(c *gin.Context) {
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
clearUser := model.User{
@@ -882,20 +796,14 @@ func EmailBind(c *gin.Context) {
}
err := user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.Email = email
// no need to check if this email already taken, because we have used verification code to check it
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -917,19 +825,13 @@ func TopUp(c *gin.Context) {
req := topUpRequest{}
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
id := c.GetInt("id")
quota, err := model.Redeem(req.Key, id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -961,7 +863,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != constant.NotifyTypeEmail && req.QuotaWarningType != constant.NotifyTypeWebhook {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -979,7 +881,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 如果是webhook类型,验证webhook地址
if req.QuotaWarningType == constant.NotifyTypeWebhook {
if req.QuotaWarningType == dto.NotifyTypeWebhook {
if req.WebhookUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -998,7 +900,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 如果是邮件类型,验证邮箱地址
if req.QuotaWarningType == constant.NotifyTypeEmail && req.NotificationEmail != "" {
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
// 验证邮箱格式
if !strings.Contains(req.NotificationEmail, "@") {
c.JSON(http.StatusOK, gin.H{
@@ -1012,32 +914,29 @@ func UpdateUserSetting(c *gin.Context) {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// 构建设置
settings := map[string]interface{}{
constant.UserSettingNotifyType: req.QuotaWarningType,
constant.UserSettingQuotaWarningThreshold: req.QuotaWarningThreshold,
"accept_unset_model_ratio_model": req.AcceptUnsetModelRatioModel,
constant.UserSettingRecordIpLog: req.RecordIpLog,
settings := dto.UserSetting{
NotifyType: req.QuotaWarningType,
QuotaWarningThreshold: req.QuotaWarningThreshold,
AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
RecordIpLog: req.RecordIpLog,
}
// 如果是webhook类型,添加webhook相关设置
if req.QuotaWarningType == constant.NotifyTypeWebhook {
settings[constant.UserSettingWebhookUrl] = req.WebhookUrl
if req.QuotaWarningType == dto.NotifyTypeWebhook {
settings.WebhookUrl = req.WebhookUrl
if req.WebhookSecret != "" {
settings[constant.UserSettingWebhookSecret] = req.WebhookSecret
settings.WebhookSecret = req.WebhookSecret
}
}
// 如果提供了通知邮箱,添加到设置中
if req.QuotaWarningType == constant.NotifyTypeEmail && req.NotificationEmail != "" {
settings[constant.UserSettingNotificationEmail] = req.NotificationEmail
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
settings.NotificationEmail = req.NotificationEmail
}
// 更新用户设置

View File

@@ -4,13 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type wechatLoginResponse struct {
@@ -150,19 +151,13 @@ func WeChatBind(c *gin.Context) {
}
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.WeChatId = wechatId
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

195
docs/api/web_api.md Normal file
View File

@@ -0,0 +1,195 @@
# One API Web 界面后端接口文档
> 本文档汇总了 **One API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
>
> 接口前缀统一为 `https://<your-domain>`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。
>
> 鉴权级别说明:
> * **公开** 不需要登录即可调用
> * **用户** 需携带用户 Token`middleware.UserAuth`
> * **管理员** 需管理员 Token`middleware.AdminAuth`
> * **Root** 仅限最高权限 Root 用户(`middleware.RootAuth`
---
## 1. 初始化 / 系统状态
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/setup | 公开 | 获取系统初始化状态 |
| POST | /api/setup | 公开 | 完成首次安装向导 |
| GET | /api/status | 公开 | 获取运行状态摘要 |
| GET | /api/uptime/status | 公开 | Uptime-Kuma 兼容状态探针 |
| GET | /api/status/test | 管理员 | 测试后端与依赖组件是否正常 |
## 2. 公共信息
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/models | 用户 | 获取前端可用模型列表 |
| GET | /api/notice | 公开 | 获取公告栏内容 |
| GET | /api/about | 公开 | 关于页面信息 |
| GET | /api/home_page_content | 公开 | 首页自定义内容 |
| GET | /api/pricing | 可匿名/用户 | 价格与套餐信息 |
| GET | /api/ratio_config | 公开 | 模型倍率配置(仅公开字段) |
## 3. 邮件 / 身份验证
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/verification | 公开 (限流) | 发送邮箱验证邮件 |
| GET | /api/reset_password | 公开 (限流) | 发送重置密码邮件 |
| POST | /api/user/reset | 公开 | 提交重置密码请求 |
## 4. OAuth / 第三方登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |
| GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 |
| GET | /api/oauth/email/bind | 公开 | 邮箱绑定 |
| GET | /api/oauth/telegram/login | 公开 | Telegram 登录 |
| GET | /api/oauth/telegram/bind | 公开 | Telegram 账户绑定 |
| GET | /api/oauth/state | 公开 | 获取随机 state防 CSRF |
## 5. 用户模块
### 5.1 账号注册/登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | /api/user/register | 公开 | 注册新账号 |
| POST | /api/user/login | 公开 | 用户登录 |
| GET | /api/user/logout | 用户 | 退出登录 |
| GET | /api/user/epay/notify | 公开 | Epay 支付回调 |
| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) |
### 5.2 用户自身操作 (需登录)
| GET | /api/user/self/groups | 用户 | 获取自己所在分组 |
| GET | /api/user/self | 用户 | 获取个人资料 |
| GET | /api/user/models | 用户 | 获取模型可见性 |
| PUT | /api/user/self | 用户 | 修改个人资料 |
| DELETE | /api/user/self | 用户 | 注销账号 |
| GET | /api/user/token | 用户 | 生成用户级别 Access Token |
| GET | /api/user/aff | 用户 | 获取推广码信息 |
| POST | /api/user/topup | 用户 | 余额直充 |
| POST | /api/user/pay | 用户 | 提交支付订单 |
| POST | /api/user/amount | 用户 | 余额支付 |
| POST | /api/user/aff_transfer | 用户 | 推广额度转账 |
| PUT | /api/user/setting | 用户 | 更新用户设置 |
### 5.3 管理员用户管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/ | 管理员 | 获取全部用户列表 |
| GET | /api/user/search | 管理员 | 搜索用户 |
| GET | /api/user/:id | 管理员 | 获取单个用户信息 |
| POST | /api/user/ | 管理员 | 创建用户 |
| POST | /api/user/manage | 管理员 | 冻结/重置等管理操作 |
| PUT | /api/user/ | 管理员 | 更新用户 |
| DELETE | /api/user/:id | 管理员 | 删除用户 |
## 6. 站点选项 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/option/ | Root | 获取全局配置 |
| PUT | /api/option/ | Root | 更新全局配置 |
| POST | /api/option/rest_model_ratio | Root | 重置模型倍率 |
| POST | /api/option/migrate_console_setting | Root | 迁移旧版控制台配置 |
## 7. 模型倍率同步 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/ratio_sync/channels | Root | 获取可同步渠道列表 |
| POST | /api/ratio_sync/fetch | Root | 从上游拉取倍率 |
## 8. 渠道管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/channel/ | 获取渠道列表 |
| GET | /api/channel/search | 搜索渠道 |
| GET | /api/channel/models | 查询渠道模型能力 |
| GET | /api/channel/models_enabled | 查询启用模型能力 |
| GET | /api/channel/:id | 获取单个渠道 |
| GET | /api/channel/test | 批量测试渠道连通性 |
| GET | /api/channel/test/:id | 单个渠道测试 |
| GET | /api/channel/update_balance | 批量刷新余额 |
| GET | /api/channel/update_balance/:id | 单个刷新余额 |
| POST | /api/channel/ | 新增渠道 |
| PUT | /api/channel/ | 更新渠道 |
| DELETE | /api/channel/disabled | 删除已禁用渠道 |
| POST | /api/channel/tag/disabled | 批量禁用标签渠道 |
| POST | /api/channel/tag/enabled | 批量启用标签渠道 |
| PUT | /api/channel/tag | 编辑渠道标签 |
| DELETE | /api/channel/:id | 删除渠道 |
| POST | /api/channel/batch | 批量删除渠道 |
| POST | /api/channel/fix | 修复渠道能力表 |
| GET | /api/channel/fetch_models/:id | 拉取单渠道模型 |
| POST | /api/channel/fetch_models | 拉取全部渠道模型 |
| POST | /api/channel/batch/tag | 批量设置渠道标签 |
| GET | /api/channel/tag/models | 根据标签获取模型 |
| POST | /api/channel/copy/:id | 复制渠道 |
## 9. Token 管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/token/ | 用户 | 获取全部 Token |
| GET | /api/token/search | 用户 | 搜索 Token |
| GET | /api/token/:id | 用户 | 获取单个 Token |
| POST | /api/token/ | 用户 | 创建 Token |
| PUT | /api/token/ | 用户 | 更新 Token |
| DELETE | /api/token/:id | 用户 | 删除 Token |
| POST | /api/token/batch | 用户 | 批量删除 Token |
## 10. 兑换码管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/redemption/ | 获取兑换码列表 |
| GET | /api/redemption/search | 搜索兑换码 |
| GET | /api/redemption/:id | 获取单个兑换码 |
| POST | /api/redemption/ | 创建兑换码 |
| PUT | /api/redemption/ | 更新兑换码 |
| DELETE | /api/redemption/invalid | 删除无效兑换码 |
| DELETE | /api/redemption/:id | 删除兑换码 |
## 11. 日志
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/log/ | 管理员 | 获取全部日志 |
| DELETE | /api/log/ | 管理员 | 删除历史日志 |
| GET | /api/log/stat | 管理员 | 日志统计 |
| GET | /api/log/self/stat | 用户 | 我的日志统计 |
| GET | /api/log/search | 管理员 | 搜索全部日志 |
| GET | /api/log/self | 用户 | 获取我的日志 |
| GET | /api/log/self/search | 用户 | 搜索我的日志 |
| GET | /api/log/token | 公开 | 根据 Token 查询日志(支持 CORS |
## 12. 数据统计
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/data/ | 管理员 | 全站用量按日期统计 |
| GET | /api/data/self | 用户 | 我的用量按日期统计 |
## 13. 分组
| GET | /api/group/ | 管理员 | 获取全部分组列表 |
## 14. Midjourney 任务
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/mj/self | 用户 | 获取自己的 MJ 任务 |
| GET | /api/mj/ | 管理员 | 获取全部 MJ 任务 |
## 15. 任务中心
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/task/self | 用户 | 获取我的任务 |
| GET | /api/task/ | 管理员 | 获取全部任务 |
## 16. 账户计费面板 (Dashboard)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /dashboard/billing/subscription | 用户 Token | 获取订阅额度信息 |
| GET | /v1/dashboard/billing/subscription | 同上 | 兼容 OpenAI SDK 路径 |
| GET | /dashboard/billing/usage | 用户 Token | 获取使用量信息 |
| GET | /v1/dashboard/billing/usage | 同上 | 兼容 OpenAI SDK 路径 |
---
> **更新日期**2025.07.17

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
<defs>
<style>
.cls-1 {
fill: #ea5e5d;
}
.cls-2 {
fill: #23af69;
}
.cls-3 {
fill: #ea5756;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<g>
<g>
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
</g>
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
</g>
<g>
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
<g>
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
</g>
<g>
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
</g>
<g>
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
docs/images/pku.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

1
docs/images/ucloud.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

9
dto/channel_settings.go Normal file
View File

@@ -0,0 +1,9 @@
package dto
type ChannelSettings struct {
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
}

View File

@@ -3,6 +3,7 @@ package dto
import (
"encoding/json"
"one-api/common"
"one-api/types"
)
type ClaudeMetadata struct {
@@ -158,6 +159,27 @@ type InputSchema struct {
Required any `json:"required,omitempty"`
}
type ClaudeWebSearchTool struct {
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
Type string `json:"type"`
Timezone string `json:"timezone,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
}
type ClaudeToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"`
}
type ClaudeRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
@@ -176,6 +198,59 @@ type ClaudeRequest struct {
Thinking *Thinking `json:"thinking,omitempty"`
}
// AddTool 添加工具到请求中
func (c *ClaudeRequest) AddTool(tool any) {
if c.Tools == nil {
c.Tools = make([]any, 0)
}
switch tools := c.Tools.(type) {
case []any:
c.Tools = append(tools, tool)
default:
// 如果Tools不是[]any类型重新初始化为[]any
c.Tools = []any{tool}
}
}
// GetTools 获取工具列表
func (c *ClaudeRequest) GetTools() []any {
if c.Tools == nil {
return nil
}
switch tools := c.Tools.(type) {
case []any:
return tools
default:
return nil
}
}
// ProcessTools 处理工具列表,支持类型断言
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
var normalTools []*Tool
var webSearchTools []*ClaudeWebSearchTool
for _, tool := range tools {
switch t := tool.(type) {
case *Tool:
normalTools = append(normalTools, t)
case *ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, t)
case Tool:
normalTools = append(normalTools, &t)
case ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, &t)
default:
// 未知类型,跳过
continue
}
}
return normalTools, webSearchTools
}
type Thinking struct {
Type string `json:"type"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
@@ -228,7 +303,7 @@ type ClaudeResponse struct {
Completion string `json:"completion,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
Model string `json:"model,omitempty"`
Error *ClaudeError `json:"error,omitempty"`
Error *types.ClaudeError `json:"error,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
Index *int `json:"index,omitempty"`
ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"`
@@ -250,8 +325,13 @@ func (c *ClaudeResponse) GetIndex() int {
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use"`
}
type ClaudeServerToolUse struct {
WebSearchRequests int `json:"web_search_requests"`
}

View File

@@ -1,5 +1,7 @@
package dto
import "one-api/types"
type OpenAIError struct {
Message string `json:"message"`
Type string `json:"type"`
@@ -14,11 +16,11 @@ type OpenAIErrorWithStatusCode struct {
}
type GeneralErrorResponse struct {
Error OpenAIError `json:"error"`
Message string `json:"message"`
Msg string `json:"msg"`
Err string `json:"err"`
ErrorMsg string `json:"error_msg"`
Error types.OpenAIError `json:"error"`
Message string `json:"message"`
Msg string `json:"msg"`
Err string `json:"err"`
ErrorMsg string `json:"error_msg"`
Header struct {
Message string `json:"message"`
} `json:"header"`

View File

@@ -57,6 +57,8 @@ type MidjourneyDto struct {
StartTime int64 `json:"startTime"`
FinishTime int64 `json:"finishTime"`
ImageUrl string `json:"imageUrl"`
VideoUrl string `json:"videoUrl"`
VideoUrls []ImgUrls `json:"videoUrls"`
Status string `json:"status"`
Progress string `json:"progress"`
FailReason string `json:"failReason"`
@@ -65,6 +67,10 @@ type MidjourneyDto struct {
Properties *Properties `json:"properties"`
}
type ImgUrls struct {
Url string `json:"url"`
}
type MidjourneyStatus struct {
Status int `json:"status"`
}

View File

@@ -7,15 +7,15 @@ import (
)
type ResponseFormat struct {
Type string `json:"type,omitempty"`
JsonSchema *FormatJsonSchema `json:"json_schema,omitempty"`
Type string `json:"type,omitempty"`
JsonSchema json.RawMessage `json:"json_schema,omitempty"`
}
type FormatJsonSchema struct {
Description string `json:"description,omitempty"`
Name string `json:"name"`
Schema any `json:"schema,omitempty"`
Strict any `json:"strict,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Schema any `json:"schema,omitempty"`
Strict json.RawMessage `json:"strict,omitempty"`
}
type GeneralOpenAIRequest struct {
@@ -55,21 +55,33 @@ type GeneralOpenAIRequest struct {
EnableThinking any `json:"enable_thinking,omitempty"` // ali
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
SearchParameters any `json:"search_parameters,omitempty"` //xai
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`
Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
// 用匿名参数接收额外参数例如ollama的think参数在此接收
Extra map[string]json.RawMessage `json:"-"`
}
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
result := make(map[string]any)
data, _ := common.EncodeJson(r)
_ = common.UnmarshalJson(data, &result)
data, _ := common.Marshal(r)
_ = common.Unmarshal(data, &result)
return result
}
func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
if strings.HasPrefix(r.Model, "o") {
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
return "developer"
}
}
return "system"
}
type ToolCallRequest struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
@@ -602,26 +614,29 @@ type WebSearchOptions struct {
UserLocation json.RawMessage `json:"user_location,omitempty"`
}
// https://platform.openai.com/docs/api-reference/responses/create
type OpenAIResponsesRequest struct {
Model string `json:"model"`
Input json.RawMessage `json:"input,omitempty"`
Include json.RawMessage `json:"include,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools []ResponsesToolsCall `json:"tools,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
Model string `json:"model"`
Input json.RawMessage `json:"input,omitempty"`
Include json.RawMessage `json:"include,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
}
type Reasoning struct {
@@ -629,23 +644,23 @@ type Reasoning struct {
Summary string `json:"summary,omitempty"`
}
type ResponsesToolsCall struct {
Type string `json:"type"`
// Web Search
UserLocation json.RawMessage `json:"user_location,omitempty"`
SearchContextSize string `json:"search_context_size,omitempty"`
// File Search
VectorStoreIds []string `json:"vector_store_ids,omitempty"`
MaxNumResults uint `json:"max_num_results,omitempty"`
Filters json.RawMessage `json:"filters,omitempty"`
// Computer Use
DisplayWidth uint `json:"display_width,omitempty"`
DisplayHeight uint `json:"display_height,omitempty"`
Environment string `json:"environment,omitempty"`
// Function
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Function json.RawMessage `json:"function,omitempty"`
Container json.RawMessage `json:"container,omitempty"`
}
//type ResponsesToolsCall struct {
// Type string `json:"type"`
// // Web Search
// UserLocation json.RawMessage `json:"user_location,omitempty"`
// SearchContextSize string `json:"search_context_size,omitempty"`
// // File Search
// VectorStoreIds []string `json:"vector_store_ids,omitempty"`
// MaxNumResults uint `json:"max_num_results,omitempty"`
// Filters json.RawMessage `json:"filters,omitempty"`
// // Computer Use
// DisplayWidth uint `json:"display_width,omitempty"`
// DisplayHeight uint `json:"display_height,omitempty"`
// Environment string `json:"environment,omitempty"`
// // Function
// Name string `json:"name,omitempty"`
// Description string `json:"description,omitempty"`
// Parameters json.RawMessage `json:"parameters,omitempty"`
// Function json.RawMessage `json:"function,omitempty"`
// Container json.RawMessage `json:"container,omitempty"`
//}

View File

@@ -1,6 +1,9 @@
package dto
import "encoding/json"
import (
"encoding/json"
"one-api/types"
)
type SimpleResponse struct {
Usage `json:"usage"`
@@ -28,7 +31,7 @@ type OpenAITextResponse struct {
Object string `json:"object"`
Created any `json:"created"`
Choices []OpenAITextResponseChoice `json:"choices"`
Error *OpenAIError `json:"error,omitempty"`
Error *types.OpenAIError `json:"error,omitempty"`
Usage `json:"usage"`
}
@@ -45,6 +48,19 @@ type OpenAIEmbeddingResponse struct {
Usage `json:"usage"`
}
type FlexibleEmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding any `json:"embedding"`
}
type FlexibleEmbeddingResponse struct {
Object string `json:"object"`
Data []FlexibleEmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
type ChatCompletionsStreamResponseChoice struct {
Delta ChatCompletionsStreamResponseChoiceDelta `json:"delta,omitempty"`
Logprobs *any `json:"logprobs"`
@@ -179,7 +195,7 @@ type Usage struct {
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// OpenRouter Params
Cost float64 `json:"cost,omitempty"`
Cost any `json:"cost,omitempty"`
}
type InputTokenDetails struct {
@@ -197,28 +213,28 @@ type OutputTokenDetails struct {
}
type OpenAIResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Status string `json:"status"`
Error *OpenAIError `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions string `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
PreviousResponseID string `json:"previous_response_id"`
Reasoning *Reasoning `json:"reasoning"`
Store bool `json:"store"`
Temperature float64 `json:"temperature"`
ToolChoice string `json:"tool_choice"`
Tools []ResponsesToolsCall `json:"tools"`
TopP float64 `json:"top_p"`
Truncation string `json:"truncation"`
Usage *Usage `json:"usage"`
User json.RawMessage `json:"user"`
Metadata json.RawMessage `json:"metadata"`
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Status string `json:"status"`
Error *types.OpenAIError `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions string `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
PreviousResponseID string `json:"previous_response_id"`
Reasoning *Reasoning `json:"reasoning"`
Store bool `json:"store"`
Temperature float64 `json:"temperature"`
ToolChoice string `json:"tool_choice"`
Tools []map[string]any `json:"tools"`
TopP float64 `json:"top_p"`
Truncation string `json:"truncation"`
Usage *Usage `json:"usage"`
User json.RawMessage `json:"user"`
Metadata json.RawMessage `json:"metadata"`
}
type IncompleteDetails struct {

View File

@@ -1,5 +1,7 @@
package dto
import "one-api/types"
const (
RealtimeEventTypeError = "error"
RealtimeEventTypeSessionUpdate = "session.update"
@@ -23,12 +25,12 @@ type RealtimeEvent struct {
EventId string `json:"event_id"`
Type string `json:"type"`
//PreviousItemId string `json:"previous_item_id"`
Session *RealtimeSession `json:"session,omitempty"`
Item *RealtimeItem `json:"item,omitempty"`
Error *OpenAIError `json:"error,omitempty"`
Response *RealtimeResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Audio string `json:"audio,omitempty"`
Session *RealtimeSession `json:"session,omitempty"`
Item *RealtimeItem `json:"item,omitempty"`
Error *types.OpenAIError `json:"error,omitempty"`
Response *RealtimeResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Audio string `json:"audio,omitempty"`
}
type RealtimeResponse struct {

16
dto/user_settings.go Normal file
View File

@@ -0,0 +1,16 @@
package dto
type UserSetting struct {
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
}
var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
)

4
go.mod
View File

@@ -27,10 +27,13 @@ require (
github.com/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0
github.com/stripe/stripe-go/v81 v81.4.0
github.com/thanhpk/randstr v1.0.6
github.com/tiktoken-go/tokenizer v0.6.2
golang.org/x/crypto v0.35.0
golang.org/x/image v0.23.0
golang.org/x/net v0.35.0
golang.org/x/sync v0.11.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2
@@ -84,7 +87,6 @@ require (
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect

6
go.sum
View File

@@ -195,6 +195,10 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -224,6 +228,7 @@ golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSO
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -232,6 +237,7 @@ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

1054
i18n/zh-cn.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,6 @@ func main() {
return
}
common.SetupLogger()
common.SysLog("New API " + common.Version + " started")
if os.Getenv("GIN_MODE") != "debug" {
gin.SetMode(gin.ReleaseMode)
@@ -69,9 +68,9 @@ func main() {
if r := recover(); r != nil {
common.SysError(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r))
// Retry once
_, fixErr := model.FixAbility()
_, _, fixErr := model.FixAbility()
if fixErr != nil {
common.SysError(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error()))
common.FatalLog(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error()))
}
}
}()
@@ -172,6 +171,8 @@ func InitResources() error {
// 加载环境变量
common.InitEnv()
common.SetupLogger()
// Initialize model settings
ratio_setting.InitRatioSettings()

View File

@@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"net/http"
"one-api/common"
"one-api/model"
@@ -121,7 +122,20 @@ func authHelper(c *gin.Context, minRole int) {
c.Set("role", role)
c.Set("id", id)
c.Set("group", session.Get("group"))
c.Set("user_group", session.Get("group"))
c.Set("use_access_token", useAccessToken)
//userCache, err := model.GetUserCache(id.(int))
//if err != nil {
// c.JSON(http.StatusOK, gin.H{
// "success": false,
// "message": err.Error(),
// })
// c.Abort()
// return
//}
//userCache.WriteContext(c)
c.Next()
}
@@ -233,30 +247,41 @@ func TokenAuth() func(c *gin.Context) {
userCache.WriteContext(c)
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("token_key", token.Key)
c.Set("token_name", token.Name)
c.Set("token_unlimited_quota", token.UnlimitedQuota)
if !token.UnlimitedQuota {
c.Set("token_quota", token.RemainQuota)
}
if token.ModelLimitsEnabled {
c.Set("token_model_limit_enabled", true)
c.Set("token_model_limit", token.GetModelLimitsMap())
} else {
c.Set("token_model_limit_enabled", false)
}
c.Set("allow_ips", token.GetIpLimitsMap())
c.Set("token_group", token.Group)
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1])
} else {
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
return
}
err = SetupContextForToken(c, token, parts...)
if err != nil {
return
}
c.Next()
}
}
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")
}
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("token_key", token.Key)
c.Set("token_name", token.Name)
c.Set("token_unlimited_quota", token.UnlimitedQuota)
if !token.UnlimitedQuota {
c.Set("token_quota", token.RemainQuota)
}
if token.ModelLimitsEnabled {
c.Set("token_model_limit_enabled", true)
c.Set("token_model_limit", token.GetModelLimitsMap())
} else {
c.Set("token_model_limit_enabled", false)
}
c.Set("allow_ips", token.GetIpLimitsMap())
c.Set("token_group", token.Group)
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1])
} else {
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
return fmt.Errorf("普通用户不支持指定渠道")
}
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"one-api/service"
"one-api/setting"
"one-api/setting/ratio_setting"
"one-api/types"
"strconv"
"strings"
"time"
@@ -21,6 +22,7 @@ import (
type ModelRequest struct {
Model string `json:"model"`
Group string `json:"group,omitempty"`
}
func Distribute() func(c *gin.Context) {
@@ -98,6 +100,10 @@ func Distribute() func(c *gin.Context) {
}
if shouldSelectChannel {
if modelRequest.Model == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, "未指定模型名称,模型名称不能为空")
return
}
var selectGroup string
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
if err != nil {
@@ -105,18 +111,17 @@ func Distribute() func(c *gin.Context) {
if userGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 可用渠道", showGroup, modelRequest.Model)
message := fmt.Sprintf("获取分组 %s 下模型 %s 可用渠道失败数据库一致性已被破坏distributor: %s", showGroup, modelRequest.Model, err.Error())
// 如果错误,但是渠道不为空,说明是数据库一致性问题
if channel != nil {
common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
message = "数据库一致性已被破坏,请联系管理员"
}
// 如果错误,而且渠道为空,说明是没有可用渠道
//if channel != nil {
// common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
// message = "数据库一致性已被破坏,请联系管理员"
//}
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message)
return
}
if channel == nil {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道(数据库一致性已被破坏", userGroup, modelRequest.Model))
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor", userGroup, modelRequest.Model))
return
}
}
@@ -172,22 +177,13 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
err = common.UnmarshalBodyReusable(c, &modelRequest)
var platform string
var relayMode int
if strings.HasPrefix(modelRequest.Model, "jimeng") {
platform = string(constant.TaskPlatformJimeng)
relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeJimengFetchByID {
shouldSelectChannel = false
}
} else {
platform = string(constant.TaskPlatformKling)
relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeKlingFetchByID {
shouldSelectChannel = false
}
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
}
c.Set("platform", platform)
c.Set("relay_mode", relayMode)
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
@@ -237,28 +233,47 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
}
c.Set("relay_mode", relayMode)
}
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
// playground chat completions
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, errors.New("无效的请求, " + err.Error())
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group)
}
return &modelRequest, shouldSelectChannel, nil
}
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) {
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
c.Set("original_model", modelName) // for retry
if channel == nil {
return
return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
c.Set("channel_id", channel.Id)
c.Set("channel_name", channel.Name)
c.Set("channel_type", channel.Type)
c.Set("channel_create_time", channel.CreatedTime)
c.Set("channel_setting", channel.GetSetting())
c.Set("param_override", channel.GetParamOverride())
if nil != channel.OpenAIOrganization && "" != *channel.OpenAIOrganization {
c.Set("channel_organization", *channel.OpenAIOrganization)
common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
}
c.Set("auto_ban", channel.GetAutoBan())
c.Set("model_mapping", channel.GetModelMapping())
c.Set("status_code_mapping", channel.GetStatusCodeMapping())
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
c.Set("base_url", channel.GetBaseURL())
common.SetContextKey(c, constant.ContextKeyChannelAutoBan, channel.GetAutoBan())
common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping())
common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping())
key, index, newAPIError := channel.GetNextEnabledKey()
if newAPIError != nil {
return newAPIError
}
if channel.ChannelInfo.IsMultiKey {
common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true)
common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index)
}
// c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
// TODO: api_version统一
switch channel.Type {
case constant.ChannelTypeAzure:
@@ -278,6 +293,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
case constant.ChannelTypeCoze:
c.Set("bot_id", channel.Other)
}
return nil
}
// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名

View File

@@ -18,7 +18,11 @@ func KlingRequestConvert() func(c *gin.Context) {
return
}
model, _ := originalReq["model"].(string)
// Support both model_name and model fields
model, _ := originalReq["model_name"].(string)
if model == "" {
model, _ = originalReq["model"].(string)
}
prompt, _ := originalReq["prompt"].(string)
unifiedReq := map[string]interface{}{
@@ -36,7 +40,7 @@ func KlingRequestConvert() func(c *gin.Context) {
// Rewrite request body and path
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
c.Request.URL.Path = "/v1/video/generations"
if image := originalReq["image"]; image == "" {
if image, ok := originalReq["image"]; !ok || image == "" {
c.Set("action", constant.TaskActionTextGenerate)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"one-api/common"
"strings"
"sync"
"github.com/samber/lo"
"gorm.io/gorm"
@@ -86,26 +87,29 @@ func getPriority(group string, model string, retry int) (int, error) {
return priorityToUse, nil
}
func getChannelQuery(group string, model string, retry int) *gorm.DB {
func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery)
if retry != 0 {
priority, err := getPriority(group, model, retry)
if err != nil {
common.SysError(fmt.Sprintf("Get priority failed: %s", err.Error()))
return nil, err
} else {
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority)
}
}
return channelQuery
return channelQuery, nil
}
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
var abilities []Ability
var err error = nil
channelQuery := getChannelQuery(group, model, retry)
channelQuery, err := getChannelQuery(group, model, retry)
if err != nil {
return nil, err
}
if common.UsingSQLite || common.UsingPostgreSQL {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
} else {
@@ -132,7 +136,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
}
}
} else {
return nil, errors.New("channel not found")
return nil, nil
}
err = DB.First(&channel, "id = ?", channel.Id).Error
return &channel, err
@@ -272,74 +276,45 @@ func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uin
return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error
}
func FixAbility() (int, error) {
var channelIds []int
count := 0
// Find all channel ids from channel table
err := DB.Model(&Channel{}).Pluck("id", &channelIds).Error
var fixLock = sync.Mutex{}
func FixAbility() (int, int, error) {
lock := fixLock.TryLock()
if !lock {
return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试")
}
defer fixLock.Unlock()
var channels []*Channel
// Find all channels
err := DB.Model(&Channel{}).Find(&channels).Error
if err != nil {
common.SysError(fmt.Sprintf("Get channel ids from channel table failed: %s", err.Error()))
return 0, err
return 0, 0, err
}
// Delete abilities of channels that are not in channel table - in batches to avoid too many placeholders
if len(channelIds) > 0 {
// Process deletion in chunks to avoid "too many placeholders" error
for _, chunk := range lo.Chunk(channelIds, 100) {
err = DB.Where("channel_id NOT IN (?)", chunk).Delete(&Ability{}).Error
if err != nil {
common.SysError(fmt.Sprintf("Delete abilities of channels (batch) that are not in channel table failed: %s", err.Error()))
return 0, err
}
}
} else {
// If no channels exist, delete all abilities
err = DB.Delete(&Ability{}).Error
if len(channels) == 0 {
return 0, 0, nil
}
successCount := 0
failCount := 0
for _, chunk := range lo.Chunk(channels, 50) {
ids := lo.Map(chunk, func(c *Channel, _ int) int { return c.Id })
// Delete all abilities of this channel
err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error
if err != nil {
common.SysError(fmt.Sprintf("Delete all abilities failed: %s", err.Error()))
return 0, err
common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
failCount += len(chunk)
continue
}
common.SysLog("Delete all abilities successfully")
return 0, nil
}
common.SysLog(fmt.Sprintf("Delete abilities of channels that are not in channel table successfully, ids: %v", channelIds))
count += len(channelIds)
// Use channelIds to find channel not in abilities table
var abilityChannelIds []int
err = DB.Table("abilities").Distinct("channel_id").Pluck("channel_id", &abilityChannelIds).Error
if err != nil {
common.SysError(fmt.Sprintf("Get channel ids from abilities table failed: %s", err.Error()))
return count, err
}
var channels []Channel
if len(abilityChannelIds) == 0 {
err = DB.Find(&channels).Error
} else {
// Process query in chunks to avoid "too many placeholders" error
err = nil
for _, chunk := range lo.Chunk(abilityChannelIds, 100) {
var channelsChunk []Channel
err = DB.Where("id NOT IN (?)", chunk).Find(&channelsChunk).Error
// Then add new abilities
for _, channel := range chunk {
err = channel.AddAbilities()
if err != nil {
common.SysError(fmt.Sprintf("Find channels not in abilities table failed: %s", err.Error()))
return count, err
common.SysError(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
failCount++
} else {
successCount++
}
channels = append(channels, channelsChunk...)
}
}
for _, channel := range channels {
err := channel.UpdateAbilities(nil)
if err != nil {
common.SysError(fmt.Sprintf("Update abilities of channel %d failed: %s", channel.Id, err.Error()))
} else {
common.SysLog(fmt.Sprintf("Update abilities of channel %d successfully", channel.Id))
count++
}
}
InitChannelCache()
return count, nil
return successCount, failCount, nil
}

View File

@@ -1,8 +1,15 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"math/rand"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/types"
"strings"
"sync"
@@ -35,8 +42,138 @@ type Channel struct {
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
// add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
}
type ChannelInfo struct {
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表key index -> status
MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
}
// Value implements driver.Valuer interface
func (c ChannelInfo) Value() (driver.Value, error) {
return common.Marshal(&c)
}
// Scan implements sql.Scanner interface
func (c *ChannelInfo) Scan(value interface{}) error {
bytesValue, _ := value.([]byte)
return common.Unmarshal(bytesValue, c)
}
func (channel *Channel) getKeys() []string {
if channel.Key == "" {
return []string{}
}
trimmed := strings.TrimSpace(channel.Key)
// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
if strings.HasPrefix(trimmed, "[") {
var arr []json.RawMessage
if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
res := make([]string, len(arr))
for i, v := range arr {
res[i] = string(v)
}
return res
}
}
// Otherwise, fall back to splitting by newline
keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n")
return keys
}
func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
// If not in multi-key mode, return the original key string directly.
if !channel.ChannelInfo.IsMultiKey {
return channel.Key, 0, nil
}
// Obtain all keys (split by \n)
keys := channel.getKeys()
if len(keys) == 0 {
// No keys available, return error, should disable the channel
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
}
statusList := channel.ChannelInfo.MultiKeyStatusList
// helper to get key status, default to enabled when missing
getStatus := func(idx int) int {
if statusList == nil {
return common.ChannelStatusEnabled
}
if status, ok := statusList[idx]; ok {
return status
}
return common.ChannelStatusEnabled
}
// Collect indexes of enabled keys
enabledIdx := make([]int, 0, len(keys))
for i := range keys {
if getStatus(i) == common.ChannelStatusEnabled {
enabledIdx = append(enabledIdx, i)
}
}
// If no specific status list or none enabled, fall back to first key
if len(enabledIdx) == 0 {
return keys[0], 0, nil
}
switch channel.ChannelInfo.MultiKeyMode {
case constant.MultiKeyModeRandom:
// Randomly pick one enabled key
selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))]
return keys[selectedIdx], selectedIdx, nil
case constant.MultiKeyModePolling:
// Use channel-specific lock to ensure thread-safe polling
lock := getChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
channelInfo, err := CacheGetChannelInfo(channel.Id)
if err != nil {
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
defer func() {
if common.DebugEnabled {
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
}
if !common.MemoryCacheEnabled {
_ = channel.SaveChannelInfo()
} else {
// CacheUpdateChannel(channel)
}
}()
// Start from the saved polling index and look for the next enabled key
start := channelInfo.MultiKeyPollingIndex
if start < 0 || start >= len(keys) {
start = 0
}
for i := 0; i < len(keys); i++ {
idx := (start + i) % len(keys)
if getStatus(idx) == common.ChannelStatusEnabled {
// update polling index for next call (point to the next position)
channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys)
return keys[idx], idx, nil
}
}
// Fallback should not happen, but return first enabled key
return keys[enabledIdx[0]], enabledIdx[0], nil
default:
// Unknown mode, default to first enabled key (or original key string)
return keys[enabledIdx[0]], enabledIdx[0], nil
}
}
func (channel *Channel) SaveChannelInfo() error {
return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error
}
func (channel *Channel) GetModels() []string {
@@ -60,7 +197,7 @@ func (channel *Channel) GetGroups() []string {
func (channel *Channel) GetOtherInfo() map[string]interface{} {
otherInfo := make(map[string]interface{})
if channel.OtherInfo != "" {
err := json.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
if err != nil {
common.SysError("failed to unmarshal other info: " + err.Error())
}
@@ -174,14 +311,20 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
}
func GetChannelById(id int, selectAll bool) (*Channel, error) {
channel := Channel{Id: id}
channel := &Channel{Id: id}
var err error = nil
if selectAll {
err = DB.First(&channel, "id = ?", id).Error
err = DB.First(channel, "id = ?", id).Error
} else {
err = DB.Omit("key").First(&channel, "id = ?", id).Error
err = DB.Omit("key").First(channel, "id = ?", id).Error
}
return &channel, err
if err != nil {
return nil, err
}
if channel == nil {
return nil, errors.New("channel not found")
}
return channel, nil
}
func BatchInsertChannels(channels []Channel) error {
@@ -265,6 +408,44 @@ func (channel *Channel) Insert() error {
}
func (channel *Channel) Update() error {
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
if channel.ChannelInfo.IsMultiKey {
var keyStr string
if channel.Key != "" {
keyStr = channel.Key
} else {
// If key is not provided, read the existing key from the database
if existing, err := GetChannelById(channel.Id, true); err == nil {
keyStr = existing.Key
}
}
// Parse the key list (supports newline separation or JSON array)
keys := []string{}
if keyStr != "" {
trimmed := strings.TrimSpace(keyStr)
if strings.HasPrefix(trimmed, "[") {
var arr []json.RawMessage
if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
keys = make([]string, len(arr))
for i, v := range arr {
keys[i] = string(v)
}
}
}
if len(keys) == 0 { // fallback to newline split
keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
}
}
channel.ChannelInfo.MultiKeySize = len(keys)
// Clean up status data that exceeds the new key count to prevent index out of range
if channel.ChannelInfo.MultiKeyStatusList != nil {
for idx := range channel.ChannelInfo.MultiKeyStatusList {
if idx >= channel.ChannelInfo.MultiKeySize {
delete(channel.ChannelInfo.MultiKeyStatusList, idx)
}
}
}
}
var err error
err = DB.Model(channel).Updates(channel).Error
if err != nil {
@@ -307,48 +488,124 @@ func (channel *Channel) Delete() error {
var channelStatusLock sync.Mutex
func UpdateChannelStatusById(id int, status int, reason string) bool {
// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling
var channelPollingLocks sync.Map
// getChannelPollingLock returns or creates a mutex for the given channel ID
func getChannelPollingLock(channelId int) *sync.Mutex {
if lock, exists := channelPollingLocks.Load(channelId); exists {
return lock.(*sync.Mutex)
}
// Create new lock for this channel
newLock := &sync.Mutex{}
actual, _ := channelPollingLocks.LoadOrStore(channelId, newLock)
return actual.(*sync.Mutex)
}
// CleanupChannelPollingLocks removes locks for channels that no longer exist
// This is optional and can be called periodically to prevent memory leaks
func CleanupChannelPollingLocks() {
var activeChannelIds []int
DB.Model(&Channel{}).Pluck("id", &activeChannelIds)
activeChannelSet := make(map[int]bool)
for _, id := range activeChannelIds {
activeChannelSet[id] = true
}
channelPollingLocks.Range(func(key, value interface{}) bool {
channelId := key.(int)
if !activeChannelSet[channelId] {
channelPollingLocks.Delete(channelId)
}
return true
})
}
func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
keys := channel.getKeys()
if len(keys) == 0 {
channel.Status = status
} else {
var keyIndex int
for i, key := range keys {
if key == usingKey {
keyIndex = i
break
}
}
if channel.ChannelInfo.MultiKeyStatusList == nil {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
}
if status == common.ChannelStatusEnabled {
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
} else {
channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
}
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
channel.Status = common.ChannelStatusAutoDisabled
info := channel.GetOtherInfo()
info["status_reason"] = "All keys are disabled"
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
}
}
}
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
if common.MemoryCacheEnabled {
channelStatusLock.Lock()
defer channelStatusLock.Unlock()
channelCache, _ := CacheGetChannel(id)
// 如果缓存渠道存在,且状态已是目标状态,直接返回
if channelCache != nil && channelCache.Status == status {
channelCache, _ := CacheGetChannel(channelId)
if channelCache == nil {
return false
}
// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
if channelCache == nil && status != common.ChannelStatusEnabled {
return false
if channelCache.ChannelInfo.IsMultiKey {
// 如果是多Key模式更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status)
//CacheUpdateChannel(channelCache)
//return true
} else {
// 如果缓存渠道存在,且状态已是目标状态,直接返回
if channelCache.Status == status {
return false
}
CacheUpdateChannelStatus(channelId, status)
}
CacheUpdateChannelStatus(id, status)
}
err := UpdateAbilityStatus(id, status == common.ChannelStatusEnabled)
shouldUpdateAbilities := false
defer func() {
if shouldUpdateAbilities {
err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
}
}
}()
channel, err := GetChannelById(channelId, true)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
return false
}
channel, err := GetChannelById(id, true)
if err != nil {
// find channel by id error, directly update status
result := DB.Model(&Channel{}).Where("id = ?", id).Update("status", status)
if result.Error != nil {
common.SysError("failed to update channel status: " + result.Error.Error())
return false
}
if result.RowsAffected == 0 {
return false
}
} else {
if channel.Status == status {
return false
}
// find channel by id success, update status and other info
info := channel.GetOtherInfo()
info["status_reason"] = reason
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
channel.Status = status
if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status
handlerMultiKeyUpdate(channel, usingKey, status)
if beforeStatus != channel.Status {
shouldUpdateAbilities = true
}
} else {
info := channel.GetOtherInfo()
info["status_reason"] = reason
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
channel.Status = status
shouldUpdateAbilities = true
}
err = channel.Save()
if err != nil {
common.SysError("failed to update channel status: " + err.Error())
@@ -514,19 +771,32 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
return tags, nil
}
func (channel *Channel) GetSetting() map[string]interface{} {
setting := make(map[string]interface{})
func (channel *Channel) ValidateSettings() error {
channelParams := &dto.ChannelSettings{}
if channel.Setting != nil && *channel.Setting != "" {
err := json.Unmarshal([]byte(*channel.Setting), &setting)
err := common.Unmarshal([]byte(*channel.Setting), channelParams)
if err != nil {
return err
}
}
return nil
}
func (channel *Channel) GetSetting() dto.ChannelSettings {
setting := dto.ChannelSettings{}
if channel.Setting != nil && *channel.Setting != "" {
err := common.Unmarshal([]byte(*channel.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
channel.Setting = nil // 清空设置以避免后续错误
_ = channel.Save() // 保存修改
}
}
return setting
}
func (channel *Channel) SetSetting(setting map[string]interface{}) {
settingBytes, err := json.Marshal(setting)
func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
settingBytes, err := common.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
return
@@ -537,7 +807,7 @@ func (channel *Channel) SetSetting(setting map[string]interface{}) {
func (channel *Channel) GetParamOverride() map[string]interface{} {
paramOverride := make(map[string]interface{})
if channel.ParamOverride != nil && *channel.ParamOverride != "" {
err := json.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
err := common.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
if err != nil {
common.SysError("failed to unmarshal param override: " + err.Error())
}

View File

@@ -14,8 +14,8 @@ import (
"github.com/gin-gonic/gin"
)
var group2model2channels map[string]map[string][]*Channel
var channelsIDM map[int]*Channel
var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled
var channelSyncLock sync.RWMutex
func InitChannelCache() {
@@ -24,7 +24,7 @@ func InitChannelCache() {
}
newChannelId2channel := make(map[int]*Channel)
var channels []*Channel
DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels)
DB.Find(&channels)
for _, channel := range channels {
newChannelId2channel[channel.Id] = channel
}
@@ -34,21 +34,22 @@ func InitChannelCache() {
for _, ability := range abilities {
groups[ability.Group] = true
}
newGroup2model2channels := make(map[string]map[string][]*Channel)
newChannelsIDM := make(map[int]*Channel)
newGroup2model2channels := make(map[string]map[string][]int)
for group := range groups {
newGroup2model2channels[group] = make(map[string][]*Channel)
newGroup2model2channels[group] = make(map[string][]int)
}
for _, channel := range channels {
newChannelsIDM[channel.Id] = channel
if channel.Status != common.ChannelStatusEnabled {
continue // skip disabled channels
}
groups := strings.Split(channel.Group, ",")
for _, group := range groups {
models := strings.Split(channel.Models, ",")
for _, model := range models {
if _, ok := newGroup2model2channels[group][model]; !ok {
newGroup2model2channels[group][model] = make([]*Channel, 0)
newGroup2model2channels[group][model] = make([]int, 0)
}
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel)
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id)
}
}
}
@@ -57,7 +58,7 @@ func InitChannelCache() {
for group, model2channels := range newGroup2model2channels {
for model, channels := range model2channels {
sort.Slice(channels, func(i, j int) bool {
return channels[i].GetPriority() > channels[j].GetPriority()
return newChannelId2channel[channels[i]].GetPriority() > newChannelId2channel[channels[j]].GetPriority()
})
newGroup2model2channels[group][model] = channels
}
@@ -65,7 +66,7 @@ func InitChannelCache() {
channelSyncLock.Lock()
group2model2channels = newGroup2model2channels
channelsIDM = newChannelsIDM
channelsIDM = newChannelId2channel
channelSyncLock.Unlock()
common.SysLog("channels synced from database")
}
@@ -108,9 +109,6 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
return nil, group, err
}
}
if channel == nil {
return nil, group, errors.New("channel not found")
}
return channel, selectGroup, nil
}
@@ -128,16 +126,27 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
channels := group2model2channels[group][model]
channelSyncLock.RUnlock()
if len(channels) == 0 {
return nil, errors.New("channel not found")
return nil, nil
}
if len(channels) == 1 {
if channel, ok := channelsIDM[channels[0]]; ok {
return channel, nil
}
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0])
}
uniquePriorities := make(map[int]bool)
for _, channel := range channels {
uniquePriorities[int(channel.GetPriority())] = true
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
uniquePriorities[int(channel.GetPriority())] = true
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
var sortedUniquePriorities []int
for priority := range uniquePriorities {
@@ -152,9 +161,13 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
// get the priority for the given retry number
var targetChannels []*Channel
for _, channel := range channels {
if channel.GetPriority() == targetPriority {
targetChannels = append(targetChannels, channel)
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
if channel.GetPriority() == targetPriority {
targetChannels = append(targetChannels, channel)
}
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
@@ -188,11 +201,35 @@ func CacheGetChannel(id int) (*Channel, error) {
c, ok := channelsIDM[id]
if !ok {
return nil, errors.New(fmt.Sprintf("当前渠道# %d已不存在", id))
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return c, nil
}
func CacheGetChannelInfo(id int) (*ChannelInfo, error) {
if !common.MemoryCacheEnabled {
channel, err := GetChannelById(id, true)
if err != nil {
return nil, err
}
return &channel.ChannelInfo, nil
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
c, ok := channelsIDM[id]
if !ok {
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return &c.ChannelInfo, nil
}
func CacheUpdateChannelStatus(id int, status int) {
if !common.MemoryCacheEnabled {
return
@@ -203,3 +240,20 @@ func CacheUpdateChannelStatus(id int, status int) {
channel.Status = status
}
}
func CacheUpdateChannel(channel *Channel) {
if !common.MemoryCacheEnabled {
return
}
channelSyncLock.Lock()
defer channelSyncLock.Unlock()
if channel == nil {
return
}
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
channelsIDM[channel.Id] = channel
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"one-api/common"
"one-api/constant"
"os"
"strings"
"time"
@@ -28,7 +27,7 @@ type Log struct {
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream" gorm:"default:false"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
@@ -50,7 +49,7 @@ func formatUserLogs(logs []*Log) {
for i := range logs {
logs[i].ChannelName = ""
var otherMap map[string]interface{}
otherMap = common.StrToMap(logs[i].Other)
otherMap, _ = common.StrToMap(logs[i].Other)
if otherMap != nil {
// delete admin
delete(otherMap, "admin_info")
@@ -100,10 +99,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
if settingMap.RecordIpLog {
needRecordIp = true
}
}
log := &Log{
@@ -136,22 +133,34 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}
}
func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens int, completionTokens int,
modelName string, tokenName string, quota int, content string, tokenId int, userQuota int, useTimeSeconds int,
isStream bool, group string, other map[string]interface{}) {
common.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, 用户调用前余额=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, userQuota, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content))
type RecordConsumeLogParams struct {
ChannelId int `json:"channel_id"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
ModelName string `json:"model_name"`
TokenName string `json:"token_name"`
Quota int `json:"quota"`
Content string `json:"content"`
TokenId int `json:"token_id"`
UserQuota int `json:"user_quota"`
UseTimeSeconds int `json:"use_time_seconds"`
IsStream bool `json:"is_stream"`
Group string `json:"group"`
Other map[string]interface{} `json:"other"`
}
func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
common.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
if !common.LogConsumeEnabled {
return
}
username := c.GetString("username")
otherStr := common.MapToJsonStr(other)
otherStr := common.MapToJsonStr(params.Other)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
if settingMap.RecordIpLog {
needRecordIp = true
}
}
log := &Log{
@@ -159,17 +168,17 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
Username: username,
CreatedAt: common.GetTimestamp(),
Type: LogTypeConsume,
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TokenName: tokenName,
ModelName: modelName,
Quota: quota,
ChannelId: channelId,
TokenId: tokenId,
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Content: params.Content,
PromptTokens: params.PromptTokens,
CompletionTokens: params.CompletionTokens,
TokenName: params.TokenName,
ModelName: params.ModelName,
Quota: params.Quota,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
UseTime: params.UseTimeSeconds,
IsStream: params.IsStream,
Group: params.Group,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
@@ -184,7 +193,7 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
}
if common.DataExportEnabled {
gopool.Go(func() {
LogQuotaData(userId, username, modelName, quota, common.GetTimestamp(), promptTokens+completionTokens)
LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens)
})
}
}

View File

@@ -57,7 +57,7 @@ func initCol() {
}
}
// log sql type and database type
common.SysLog("Using Log SQL Type: " + common.LogSqlType)
//common.SysLog("Using Log SQL Type: " + common.LogSqlType)
}
var DB *gorm.DB
@@ -225,12 +225,6 @@ func InitLogDB() (err error) {
if !common.IsMasterNode {
return nil
}
//if common.UsingMySQL {
// _, _ = sqlDB.Exec("DROP INDEX idx_channels_key ON channels;") // TODO: delete this line when most users have upgraded
// _, _ = sqlDB.Exec("ALTER TABLE midjourneys MODIFY action VARCHAR(40);") // TODO: delete this line when most users have upgraded
// _, _ = sqlDB.Exec("ALTER TABLE midjourneys MODIFY progress VARCHAR(30);") // TODO: delete this line when most users have upgraded
// _, _ = sqlDB.Exec("ALTER TABLE midjourneys MODIFY status VARCHAR(20);") // TODO: delete this line when most users have upgraded
//}
common.SysLog("database migration started")
err = migrateLOGDB()
return err
@@ -266,7 +260,6 @@ func migrateDB() error {
func migrateDBFast() error {
var wg sync.WaitGroup
errChan := make(chan error, 12) // Buffer size matches number of migrations
migrations := []struct {
model interface{}
@@ -285,6 +278,8 @@ func migrateDBFast() error {
{&Task{}, "Task"},
{&Setup{}, "Setup"},
}
// 动态计算migration数量确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
for _, m := range migrations {
wg.Add(1)

View File

@@ -14,6 +14,8 @@ type Midjourney struct {
StartTime int64 `json:"start_time" gorm:"index"`
FinishTime int64 `json:"finish_time" gorm:"index"`
ImageUrl string `json:"image_url"`
VideoUrl string `json:"video_url"`
VideoUrls string `json:"video_urls"`
Status string `json:"status" gorm:"type:varchar(20);index"`
Progress string `json:"progress" gorm:"type:varchar(30);index"`
FailReason string `json:"fail_reason"`

View File

@@ -74,7 +74,13 @@ func InitOptionMap() {
common.OptionMap["EpayId"] = ""
common.OptionMap["EpayKey"] = ""
common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp)
common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
common.OptionMap["StripePriceId"] = setting.StripePriceId
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -306,8 +312,20 @@ func updateOptionMap(key string, value string) (err error) {
setting.EpayKey = value
case "Price":
setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
case "MinTopUp":
setting.MinTopUp, _ = strconv.Atoi(value)
case "StripeApiSecret":
setting.StripeApiSecret = value
case "StripeWebhookSecret":
setting.StripeWebhookSecret = value
case "StripePriceId":
setting.StripePriceId = value
case "StripeUnitPrice":
setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "StripeMinTopUp":
setting.StripeMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":

View File

@@ -116,7 +116,7 @@ func updatePricing() {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _ := ratio_setting.GetModelRatio(model)
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0

View File

@@ -20,8 +20,8 @@ type Token struct {
AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"`
ModelLimitsEnabled bool `json:"model_limits_enabled" gorm:"default:false"`
UnlimitedQuota bool `json:"unlimited_quota"`
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"`
AllowIps *string `json:"allow_ips" gorm:"default:''"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota

View File

@@ -1,13 +1,22 @@
package model
import (
"errors"
"fmt"
"one-api/common"
"gorm.io/gorm"
)
type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no"`
CreateTime int64 `json:"create_time"`
Status string `json:"status"`
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
}
func (topUp *TopUp) Insert() error {
@@ -41,3 +50,51 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
}
return topUp
}
func Recharge(referenceId string, customerId string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota float64
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return errors.New("充值失败," + err.Error())
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%d", common.FormatQuota(int(quota)), topUp.Amount))
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"one-api/common"
"one-api/dto"
"strconv"
"strings"
@@ -42,6 +43,7 @@ type User struct {
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
}
func (user *User) ToBaseUser() *UserBase {
@@ -68,14 +70,18 @@ func (user *User) SetAccessToken(token string) {
user.AccessToken = &token
}
func (user *User) GetSetting() map[string]interface{} {
if user.Setting == "" {
return nil
func (user *User) GetSetting() dto.UserSetting {
setting := dto.UserSetting{}
if user.Setting != "" {
err := json.Unmarshal([]byte(user.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
}
}
return common.StrToMap(user.Setting)
return setting
}
func (user *User) SetSetting(setting map[string]interface{}) {
func (user *User) SetSetting(setting dto.UserSetting) {
settingBytes, err := json.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
@@ -626,7 +632,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
}
// GetUserSetting gets setting from Redis first, falls back to DB if needed
func GetUserSetting(id int, fromDB bool) (settingMap map[string]interface{}, err error) {
func GetUserSetting(id int, fromDB bool) (settingMap dto.UserSetting, err error) {
var setting string
defer func() {
// Update Redis cache asynchronously on successful DB read
@@ -648,10 +654,12 @@ func GetUserSetting(id int, fromDB bool) (settingMap map[string]interface{}, err
fromDB = true
err = DB.Model(&User{}).Where("id = ?", id).Select("setting").Find(&setting).Error
if err != nil {
return map[string]interface{}{}, err
return settingMap, err
}
return common.StrToMap(setting), nil
userBase := &UserBase{
Setting: setting,
}
return userBase.GetSetting(), nil
}
func IncreaseUserQuota(id int, quota int, db bool) (err error) {

View File

@@ -1,10 +1,10 @@
package model
import (
"encoding/json"
"fmt"
"one-api/common"
"one-api/constant"
"one-api/dto"
"time"
"github.com/gin-gonic/gin"
@@ -32,20 +32,15 @@ func (user *UserBase) WriteContext(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUserSetting, user.GetSetting())
}
func (user *UserBase) GetSetting() map[string]interface{} {
if user.Setting == "" {
return nil
func (user *UserBase) GetSetting() dto.UserSetting {
setting := dto.UserSetting{}
if user.Setting != "" {
err := common.Unmarshal([]byte(user.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
}
}
return common.StrToMap(user.Setting)
}
func (user *UserBase) SetSetting(setting map[string]interface{}) {
settingBytes, err := json.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
return
}
user.Setting = string(settingBytes)
return setting
}
// getUserCacheKey returns the key for user cache
@@ -174,11 +169,10 @@ func getUserNameCache(userId int) (string, error) {
return cache.Username, nil
}
func getUserSettingCache(userId int) (map[string]interface{}, error) {
setting := make(map[string]interface{})
func getUserSettingCache(userId int) (dto.UserSetting, error) {
cache, err := GetUserCache(userId)
if err != nil {
return setting, err
return dto.UserSetting{}, err
}
return cache.GetSetting(), nil
}

View File

@@ -3,7 +3,6 @@ package relay
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/dto"
@@ -12,7 +11,10 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.AudioRequest, error) {
@@ -54,13 +56,13 @@ func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
return audioRequest, nil
}
func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
relayInfo := relaycommon.GenRelayInfoOpenAIAudio(c)
audioRequest, err := getAndValidAudioRequest(c, relayInfo)
if err != nil {
common.LogError(c, fmt.Sprintf("getAndValidAudioRequest failed: %s", err.Error()))
return service.OpenAIErrorWrapper(err, "invalid_audio_request", http.StatusBadRequest)
return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
promptTokens := 0
@@ -73,7 +75,7 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
priceData, err := helper.ModelPriceHelper(c, relayInfo, preConsumedTokens, 0)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
}
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
@@ -88,23 +90,23 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
err = helper.ModelMappedHelper(c, relayInfo, audioRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil {
return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest)
return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
}
adaptor.Init(relayInfo)
ioReader, err := adaptor.ConvertAudioRequest(c, relayInfo, *audioRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "convert_request_failed", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
resp, err := adaptor.DoRequest(c, relayInfo, ioReader)
if err != nil {
return service.OpenAIErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeDoRequestFailed)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
@@ -112,18 +114,18 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
openaiErr = service.RelayErrorHandler(httpResp, false)
newAPIError = service.RelayErrorHandler(httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
return openaiErr
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
}
usage, openaiErr := adaptor.DoResponse(c, httpResp, relayInfo)
if openaiErr != nil {
usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo)
if newAPIError != nil {
// reset status code 重置状态码
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
return openaiErr
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -21,7 +22,7 @@ type Adaptor interface {
ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error)
ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error)
DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError)
GetModelList() []string
GetChannelName() string
ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error)

View File

@@ -10,6 +10,7 @@ import (
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -99,7 +100,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info)
@@ -109,9 +110,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
err, usage = RerankHandler(c, resp, info)
default:
if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info)
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
err, usage = openai.OpenaiHandler(c, resp, info)
usage, err = openai.OpenaiHandler(c, info, resp)
}
}
return

View File

@@ -4,15 +4,17 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"one-api/types"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func oaiImage2Ali(request dto.ImageRequest) *AliImageRequest {
@@ -124,49 +126,46 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, info *relayc
return &imageResponse
}
func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
responseFormat := c.GetString("response_format")
var aliTaskResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &aliTaskResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliTaskResponse.Message != "" {
common.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message)
return service.OpenAIErrorWrapper(errors.New(aliTaskResponse.Message), "ali_async_task_failed", http.StatusInternalServerError), nil
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
}
aliResponse, _, err := asyncTaskWait(info, aliTaskResponse.Output.TaskId)
if err != nil {
return service.OpenAIErrorWrapper(err, "ali_async_task_wait_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponse), nil
}
if aliResponse.Output.TaskStatus != "SUCCEEDED" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: aliResponse.Output.Message,
Type: "ali_error",
Param: "",
Code: aliResponse.Output.Code,
},
StatusCode: resp.StatusCode,
}, nil
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Output.Message,
Type: "ali_error",
Param: "",
Code: aliResponse.Output.Code,
}, resp.StatusCode), nil
}
fullTextResponse := responseAli2OpenAIImage(c, aliResponse, info, responseFormat)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, nil
c.Writer.Write(jsonResponse)
return nil, &dto.Usage{}
}

View File

@@ -7,7 +7,7 @@ import (
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/service"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -31,29 +31,26 @@ func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
}
}
func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
common.CloseResponseBodyGracefully(resp)
var aliResponse AliRerankResponse
err = json.Unmarshal(responseBody, &aliResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliResponse.Code != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: aliResponse.Message,
Type: aliResponse.Code,
Param: aliResponse.RequestId,
Code: aliResponse.Code,
},
StatusCode: resp.StatusCode,
}, nil
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Message,
Type: aliResponse.Code,
Param: aliResponse.RequestId,
Code: aliResponse.Code,
}, resp.StatusCode), nil
}
usage := dto.Usage{
@@ -68,14 +65,10 @@ func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
jsonResponse, err := json.Marshal(rerankResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil
}
c.Writer.Write(jsonResponse)
return nil, &usage
}

View File

@@ -8,9 +8,10 @@ import (
"one-api/common"
"one-api/dto"
"one-api/relay/helper"
"one-api/service"
"strings"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -38,11 +39,11 @@ func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingReque
}
}
func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
var fullTextResponse dto.OpenAIEmbeddingResponse
func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var fullTextResponse dto.FlexibleEmbeddingResponse
err := json.NewDecoder(resp.Body).Decode(&fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
common.CloseResponseBodyGracefully(resp)
@@ -53,11 +54,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
}
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
c.Writer.Write(jsonResponse)
return nil, &fullTextResponse.Usage
}
@@ -119,7 +120,7 @@ func streamResponseAli2OpenAI(aliResponse *AliResponse) *dto.ChatCompletionsStre
return &response
}
func aliStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func aliStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var usage dto.Usage
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
@@ -174,32 +175,29 @@ func aliStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWith
return nil, &usage
}
func aliHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var aliResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &aliResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliResponse.Code != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: aliResponse.Message,
Type: aliResponse.Code,
Param: aliResponse.RequestId,
Code: aliResponse.Code,
},
StatusCode: resp.StatusCode,
}, nil
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Message,
Type: "ali_error",
Param: aliResponse.RequestId,
Code: aliResponse.Code,
}, resp.StatusCode), nil
}
fullTextResponse := responseAli2OpenAI(&aliResponse)
jsonResponse, err := json.Marshal(fullTextResponse)
jsonResponse, err := common.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)

View File

@@ -203,11 +203,14 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
}
}
func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
return doRequest(c, req, info)
}
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
var client *http.Client
var err error
if proxyURL, ok := info.ChannelSetting["proxy"]; ok {
client, err = service.NewProxyHttpClient(proxyURL.(string))
if info.ChannelSetting.Proxy != "" {
client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy)
if err != nil {
return nil, fmt.Errorf("new proxy http client failed: %w", err)
}
@@ -220,7 +223,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
helper.SetEventStreamHeaders(c)
// 处理流式请求的 ping 保活
generalSettings := operation_setting.GetGeneralSetting()
if generalSettings.PingIntervalEnabled {
if generalSettings.PingIntervalEnabled && !info.DisablePing {
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
stopPinger = startPingKeepAlive(c, pingInterval)
// 使用defer确保在任何情况下都能停止ping goroutine

View File

@@ -8,6 +8,7 @@ import (
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
"one-api/setting/model_setting"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -84,7 +85,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return nil, nil
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = awsStreamHandler(c, resp, info, a.RequestMode)
} else {

View File

@@ -3,19 +3,22 @@ package aws
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
)
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
@@ -65,24 +68,21 @@ func awsModelCrossRegion(awsModelId, awsRegionPrefix string) string {
return modelPrefix + "." + awsModelId
}
func awsModelID(requestModel string) (string, error) {
func awsModelID(requestModel string) string {
if awsModelID, ok := awsModelIDMap[requestModel]; ok {
return awsModelID, nil
return awsModelID
}
return requestModel, nil
return requestModel
}
func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
awsCli, err := newAwsClient(c, info)
if err != nil {
return wrapErr(errors.Wrap(err, "newAwsClient")), nil
return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil
}
awsModelId, err := awsModelID(c.GetString("request_model"))
if err != nil {
return wrapErr(errors.Wrap(err, "awsModelID")), nil
}
awsModelId := awsModelID(c.GetString("request_model"))
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
@@ -98,42 +98,42 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
claudeReq_, ok := c.Get("converted_request")
if !ok {
return wrapErr(errors.New("request not found")), nil
return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil
}
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = json.Marshal(awsClaudeReq)
if err != nil {
return wrapErr(errors.Wrap(err, "marshal request")), nil
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
if err != nil {
return wrapErr(errors.Wrap(err, "InvokeModel")), nil
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
}
claudeInfo := &claude.ClaudeResponseInfo{
ResponseId: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
ResponseId: helper.GetResponseID(c),
Created: common.GetTimestamp(),
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
Usage: &dto.Usage{},
}
claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage)
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage)
if handlerErr != nil {
return handlerErr, nil
}
return nil, claudeInfo.Usage
}
func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
awsCli, err := newAwsClient(c, info)
if err != nil {
return wrapErr(errors.Wrap(err, "newAwsClient")), nil
return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil
}
awsModelId, err := awsModelID(c.GetString("request_model"))
if err != nil {
return wrapErr(errors.Wrap(err, "awsModelID")), nil
}
awsModelId := awsModelID(c.GetString("request_model"))
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
@@ -149,25 +149,25 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
claudeReq_, ok := c.Get("converted_request")
if !ok {
return wrapErr(errors.New("request not found")), nil
return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil
}
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = json.Marshal(awsClaudeReq)
if err != nil {
return wrapErr(errors.Wrap(err, "marshal request")), nil
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)
if err != nil {
return wrapErr(errors.Wrap(err, "InvokeModelWithResponseStream")), nil
return types.NewError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeChannelAwsClientError), nil
}
stream := awsResp.GetStream()
defer stream.Close()
claudeInfo := &claude.ClaudeResponseInfo{
ResponseId: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
ResponseId: helper.GetResponseID(c),
Created: common.GetTimestamp(),
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
@@ -176,18 +176,18 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
for event := range stream.Events() {
switch v := event.(type) {
case *types.ResponseStreamMemberChunk:
case *bedrockruntimeTypes.ResponseStreamMemberChunk:
info.SetFirstResponseTime()
respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes), RequestModeMessage)
if respErr != nil {
return respErr, nil
}
case *types.UnknownUnionMember:
case *bedrockruntimeTypes.UnknownUnionMember:
fmt.Println("unknown tag:", v.Tag)
return wrapErr(errors.New("unknown response type")), nil
return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil
default:
fmt.Println("union is nil or unknown type")
return wrapErr(errors.New("nil or unknown response type")), nil
return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil
}
}

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
@@ -140,15 +141,15 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = baiduStreamHandler(c, resp)
err, usage = baiduStreamHandler(c, info, resp)
} else {
switch info.RelayMode {
case constant.RelayModeEmbeddings:
err, usage = baiduEmbeddingHandler(c, resp)
err, usage = baiduEmbeddingHandler(c, info, resp)
default:
err, usage = baiduHandler(c, resp)
err, usage = baiduHandler(c, info, resp)
}
}
return

View File

@@ -1,21 +1,23 @@
package baidu
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2
@@ -110,92 +112,49 @@ func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *dto.OpenAI
return &openAIEmbeddingResponse
}
func baiduStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
var usage dto.Usage
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n"); i >= 0 {
return i + 1, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
dataChan := make(chan string)
stopChan := make(chan bool)
go func() {
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // ignore blank line or wrong format
continue
}
data = data[6:]
dataChan <- data
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
var baiduResponse BaiduChatStreamResponse
err := json.Unmarshal([]byte(data), &baiduResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return true
}
if baiduResponse.Usage.TotalTokens != 0 {
usage.TotalTokens = baiduResponse.Usage.TotalTokens
usage.PromptTokens = baiduResponse.Usage.PromptTokens
usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens
}
response := streamResponseBaidu2OpenAI(&baiduResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysError("error marshalling stream response: " + err.Error())
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
usage := &dto.Usage{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
var baiduResponse BaiduChatStreamResponse
err := common.Unmarshal([]byte(data), &baiduResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return true
case <-stopChan:
c.Render(-1, common.CustomEvent{Data: "data: [DONE]"})
return false
}
if baiduResponse.Usage.TotalTokens != 0 {
usage.TotalTokens = baiduResponse.Usage.TotalTokens
usage.PromptTokens = baiduResponse.Usage.PromptTokens
usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens
}
response := streamResponseBaidu2OpenAI(&baiduResponse)
err = helper.ObjectData(c, response)
if err != nil {
common.SysError("error sending stream response: " + err.Error())
}
return true
})
common.CloseResponseBodyGracefully(resp)
return nil, &usage
return nil, usage
}
func baiduHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var baiduResponse BaiduChatResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &baiduResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
if baiduResponse.ErrorMsg != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: baiduResponse.ErrorMsg,
Type: "baidu_error",
Param: "",
Code: baiduResponse.ErrorCode,
},
StatusCode: resp.StatusCode,
}, nil
return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil
}
fullTextResponse := responseBaidu2OpenAI(&baiduResponse)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
@@ -203,32 +162,24 @@ func baiduHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStat
return nil, &fullTextResponse.Usage
}
func baiduEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var baiduResponse BaiduEmbeddingResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &baiduResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
if baiduResponse.ErrorMsg != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: baiduResponse.ErrorMsg,
Type: "baidu_error",
Param: "",
Code: baiduResponse.ErrorCode,
},
StatusCode: resp.StatusCode,
}, nil
return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil
}
fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
@@ -42,7 +43,16 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", "Bearer "+info.ApiKey)
keyParts := strings.Split(info.ApiKey, "|")
if len(keyParts) == 0 || keyParts[0] == "" {
return errors.New("invalid API key: authorization token is required")
}
if len(keyParts) > 1 {
if keyParts[1] != "" {
req.Set("appid", keyParts[1])
}
}
req.Set("Authorization", "Bearer "+keyParts[0])
return nil
}
@@ -83,11 +93,11 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info)
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
err, usage = openai.OpenaiHandler(c, resp, info)
usage, err = openai.OpenaiHandler(c, info, resp)
}
return
}

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/setting/model_setting"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
@@ -94,7 +95,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = ClaudeStreamHandler(c, resp, info, a.RequestMode)
} else {

View File

@@ -12,11 +12,18 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting/model_setting"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
const (
WebSearchMaxUsesLow = 1
WebSearchMaxUsesMedium = 5
WebSearchMaxUsesHigh = 10
)
func stopReasonClaude2OpenAI(reason string) string {
switch reason {
case "stop_sequence":
@@ -64,7 +71,7 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
}
func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]dto.Tool, 0, len(textRequest.Tools))
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
@@ -84,10 +91,62 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
}
claudeTool.InputSchema[s] = a
}
claudeTools = append(claudeTools, claudeTool)
claudeTools = append(claudeTools, &claudeTool)
}
}
// Web search tool
// https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool
if textRequest.WebSearchOptions != nil {
webSearchTool := dto.ClaudeWebSearchTool{
Type: "web_search_20250305",
Name: "web_search",
}
// 处理 user_location
if textRequest.WebSearchOptions.UserLocation != nil {
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
Type: "approximate", // 固定为 "approximate"
}
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
anthropicUserLocation.Timezone = timezone
}
if country, ok := approximateData["country"].(string); ok && country != "" {
anthropicUserLocation.Country = country
}
if region, ok := approximateData["region"].(string); ok && region != "" {
anthropicUserLocation.Region = region
}
if city, ok := approximateData["city"].(string); ok && city != "" {
anthropicUserLocation.City = city
}
}
}
webSearchTool.UserLocation = anthropicUserLocation
}
// 处理 search_context_size 转换为 max_uses
if textRequest.WebSearchOptions.SearchContextSize != "" {
switch textRequest.WebSearchOptions.SearchContextSize {
case "low":
webSearchTool.MaxUses = WebSearchMaxUsesLow
case "medium":
webSearchTool.MaxUses = WebSearchMaxUsesMedium
case "high":
webSearchTool.MaxUses = WebSearchMaxUsesHigh
}
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
MaxTokens: textRequest.MaxTokens,
@@ -99,6 +158,14 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
Tools: claudeTools,
}
// 处理 tool_choice 和 parallel_tool_calls
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
claudeToolChoice := mapToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
if claudeToolChoice != nil {
claudeRequest.ToolChoice = claudeToolChoice
}
}
if claudeRequest.MaxTokens == 0 {
claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
}
@@ -123,9 +190,30 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
}
if textRequest.ReasoningEffort != "" {
switch textRequest.ReasoningEffort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](4096),
}
}
}
// 指定了 reasoning 参数,覆盖 budgetTokens
if textRequest.Reasoning != nil {
var reasoning openrouter.RequestReasoning
if err := common.UnmarshalJson(textRequest.Reasoning, &reasoning); err != nil {
if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil {
return nil, err
}
@@ -517,22 +605,15 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
return true
}
func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string, requestMode int) *dto.OpenAIErrorWithStatusCode {
func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string, requestMode int) *types.NewAPIError {
var claudeResponse dto.ClaudeResponse
err := common.UnmarshalJsonStr(data, &claudeResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return service.OpenAIErrorWrapper(err, "stream_response_error", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if claudeResponse.Error != nil && claudeResponse.Error.Type != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Code: "stream_response_error",
Type: claudeResponse.Error.Type,
Message: claudeResponse.Error.Message,
},
StatusCode: http.StatusInternalServerError,
}
return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError)
}
if info.RelayFormat == relaycommon.RelayFormatClaude {
FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo)
@@ -593,15 +674,15 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
}
}
func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
claudeInfo := &ClaudeResponseInfo{
ResponseId: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
ResponseId: helper.GetResponseID(c),
Created: common.GetTimestamp(),
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
Usage: &dto.Usage{},
}
var err *dto.OpenAIErrorWithStatusCode
var err *types.NewAPIError
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
err = HandleStreamResponseData(c, info, claudeInfo, data, requestMode)
if err != nil {
@@ -617,21 +698,14 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
return nil, claudeInfo.Usage
}
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *dto.OpenAIErrorWithStatusCode {
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError {
var claudeResponse dto.ClaudeResponse
err := common.UnmarshalJson(data, &claudeResponse)
err := common.Unmarshal(data, &claudeResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_claude_response_failed", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if claudeResponse.Error != nil && claudeResponse.Error.Type != "" {
return &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: claudeResponse.Error.Message,
Type: claudeResponse.Error.Type,
Code: claudeResponse.Error.Type,
},
StatusCode: http.StatusInternalServerError,
}
return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError)
}
if requestMode == RequestModeCompletion {
completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName)
@@ -652,21 +726,25 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
openaiResponse.Usage = *claudeInfo.Usage
responseData, err = json.Marshal(openaiResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError)
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
case relaycommon.RelayFormatClaude:
responseData = data
}
if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
common.IOCopyBytesGracefully(c, nil, responseData)
return nil
}
func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
defer common.CloseResponseBodyGracefully(resp)
claudeInfo := &ClaudeResponseInfo{
ResponseId: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
ResponseId: helper.GetResponseID(c),
Created: common.GetTimestamp(),
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
@@ -674,7 +752,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *r
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
if common.DebugEnabled {
println("responseBody: ", string(responseBody))
@@ -685,3 +763,51 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *r
}
return nil, claudeInfo.Usage
}
func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
var claudeToolChoice *dto.ClaudeToolChoice
// 处理 tool_choice 字符串值
if toolChoiceStr, ok := toolChoice.(string); ok {
switch toolChoiceStr {
case "auto":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
case "required":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "any",
}
case "none":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "none",
}
}
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
// 处理 tool_choice 对象值
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if toolName, ok := function["name"].(string); ok {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "tool",
Name: toolName,
}
}
}
}
// 处理 parallel_tool_calls
if parallelToolCalls != nil {
if claudeToolChoice == nil {
// 如果没有 tool_choice但有 parallel_tool_calls创建默认的 auto 类型
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
}
// 设置 disable_parallel_tool_use
// 如果 parallel_tool_calls 为 true则 disable_parallel_tool_use 为 false
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
}
return claudeToolChoice
}

View File

@@ -10,6 +10,7 @@ import (
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -94,20 +95,20 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case constant.RelayModeEmbeddings:
fallthrough
case constant.RelayModeChatCompletions:
if info.IsStream {
err, usage = cfStreamHandler(c, resp, info)
err, usage = cfStreamHandler(c, info, resp)
} else {
err, usage = cfHandler(c, resp, info)
err, usage = cfHandler(c, info, resp)
}
case constant.RelayModeAudioTranslation:
fallthrough
case constant.RelayModeAudioTranscription:
err, usage = cfSTTHandler(c, resp, info)
err, usage = cfSTTHandler(c, info, resp)
}
return
}

View File

@@ -3,7 +3,6 @@ package cloudflare
import (
"bufio"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -11,8 +10,11 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfRequest {
@@ -25,7 +27,7 @@ func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfReque
}
}
func cfStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
@@ -86,16 +88,16 @@ func cfStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
return nil, usage
}
func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cfHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
common.CloseResponseBodyGracefully(resp)
var response dto.TextResponse
err = json.Unmarshal(responseBody, &response)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
response.Model = info.UpstreamModelName
var responseText string
@@ -107,7 +109,7 @@ func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo)
response.Id = helper.GetResponseID(c)
jsonResponse, err := json.Marshal(response)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
@@ -115,16 +117,16 @@ func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo)
return nil, usage
}
func cfSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cfSTTHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var cfResp CfAudioResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &cfResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
audioResp := &dto.AudioResponse{
@@ -133,7 +135,7 @@ func cfSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayIn
jsonResponse, err := json.Marshal(audioResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -71,14 +72,14 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeRerank {
err, usage = cohereRerankHandler(c, resp, info)
usage, err = cohereRerankHandler(c, resp, info)
} else {
if info.IsStream {
err, usage = cohereStreamHandler(c, resp, info)
usage, err = cohereStreamHandler(c, info, resp) // TODO: fix this
} else {
err, usage = cohereHandler(c, resp, info.UpstreamModelName, info.PromptTokens)
usage, err = cohereHandler(c, info, resp)
}
}
return

View File

@@ -3,7 +3,6 @@ package cohere
import (
"bufio"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -11,8 +10,11 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func requestOpenAI2Cohere(textRequest dto.GeneralOpenAIRequest) *CohereRequest {
@@ -76,7 +78,7 @@ func stopReasonCohere2OpenAI(reason string) string {
}
}
func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
@@ -164,20 +166,20 @@ func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
if usage.PromptTokens == 0 {
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
return nil, usage
return usage, nil
}
func cohereHandler(c *gin.Context, resp *http.Response, modelName string, promptTokens int) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
createdTime := common.GetTimestamp()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
var cohereResp CohereResponseResult
err = json.Unmarshal(responseBody, &cohereResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
usage := dto.Usage{}
usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens
@@ -188,7 +190,7 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
openaiResp.Id = cohereResp.ResponseId
openaiResp.Created = createdTime
openaiResp.Object = "chat.completion"
openaiResp.Model = modelName
openaiResp.Model = info.UpstreamModelName
openaiResp.Usage = usage
openaiResp.Choices = []dto.OpenAITextResponseChoice{
@@ -201,24 +203,24 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
jsonResponse, err := json.Marshal(openaiResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &usage
_, _ = c.Writer.Write(jsonResponse)
return &usage, nil
}
func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
var cohereResp CohereRerankResponseResult
err = json.Unmarshal(responseBody, &cohereResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
usage := dto.Usage{}
if cohereResp.Meta.BilledUnits.InputTokens == 0 {
@@ -237,10 +239,10 @@ func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.
jsonResponse, err := json.Marshal(rerankResp)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &usage
return &usage, nil
}

View File

@@ -9,6 +9,7 @@ import (
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/common"
"one-api/types"
"time"
"github.com/gin-gonic/gin"
@@ -95,11 +96,11 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *common.RelayInfo, requestBody
}
// DoResponse implements channel.Adaptor.
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *common.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *common.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = cozeChatStreamHandler(c, resp, info)
usage, err = cozeChatStreamHandler(c, info, resp)
} else {
err, usage = cozeChatHandler(c, resp, info)
usage, err = cozeChatHandler(c, info, resp)
}
return
}

View File

@@ -12,6 +12,7 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
@@ -43,10 +44,10 @@ func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *C
return cozeRequest
}
func cozeChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
// convert coze response to openai response
@@ -55,10 +56,10 @@ func cozeChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
response.Model = info.UpstreamModelName
err = json.Unmarshal(responseBody, &cozeResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if cozeResponse.Code != 0 {
return service.OpenAIErrorWrapper(errors.New(cozeResponse.Msg), fmt.Sprintf("%d", cozeResponse.Code), http.StatusInternalServerError), nil
return nil, types.NewError(errors.New(cozeResponse.Msg), types.ErrorCodeBadResponseBody)
}
// 从上下文获取 usage
var usage dto.Usage
@@ -85,16 +86,16 @@ func cozeChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
}
jsonResponse, err := json.Marshal(response)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, _ = c.Writer.Write(jsonResponse)
return nil, &usage
return &usage, nil
}
func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
@@ -135,7 +136,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
}
if err := scanner.Err(); err != nil {
return service.OpenAIErrorWrapper(err, "stream_scanner_error", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
helper.Done(c)
@@ -143,7 +144,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
}
return nil, usage
return usage, nil
}
func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) {
@@ -278,8 +279,8 @@ func getChatDetail(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (*ht
func doRequest(req *http.Request, info *relaycommon.RelayInfo) (*http.Response, error) {
var client *http.Client
var err error // 声明 err 变量
if proxyURL, ok := info.ChannelSetting["proxy"]; ok {
client, err = service.NewProxyHttpClient(proxyURL.(string))
if info.ChannelSetting.Proxy != "" {
client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy)
if err != nil {
return nil, fmt.Errorf("new proxy http client failed: %w", err)
}

View File

@@ -10,6 +10,7 @@ import (
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
@@ -81,11 +82,11 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info)
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
err, usage = openai.OpenaiHandler(c, resp, info)
usage, err = openai.OpenaiHandler(c, info, resp)
}
return
}

View File

@@ -8,6 +8,7 @@ import (
"one-api/dto"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/types"
"github.com/gin-gonic/gin"
)
@@ -96,11 +97,11 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = difyStreamHandler(c, resp, info)
return difyStreamHandler(c, info, resp)
} else {
err, usage = difyHandler(c, resp, info)
return difyHandler(c, info, resp)
}
return
}

View File

@@ -14,6 +14,7 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"os"
"strings"
@@ -209,7 +210,7 @@ func streamResponseDify2OpenAI(difyResponse DifyChunkChatCompletionResponse) *dt
return &response
}
func difyStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var responseText string
usage := &dto.Usage{}
var nodeToken int
@@ -247,20 +248,20 @@ func difyStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
usage.CompletionTokens += nodeToken
return nil, usage
return usage, nil
}
func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
func difyHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var difyResponse DifyChatCompletionResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &difyResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
fullTextResponse := dto.OpenAITextResponse{
Id: difyResponse.ConversationId,
@@ -279,10 +280,10 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &difyResponse.MetaData.Usage
c.Writer.Write(jsonResponse)
return &difyResponse.MetaData.Usage, nil
}

Some files were not shown because too many files have changed in this diff Show More