Compare commits

...

224 Commits

Author SHA1 Message Date
t0ng7u
380e1b7d56 🔐 fix(oauth): stop authorize flow from bouncing to /console; respect next and redirect unauthenticated users to consent
Problem
- Starting OAuth from Discourse hit GET /api/oauth/authorize and 302’d to /login?next=/oauth/consent…
- The login page and AuthRedirect always navigated to /console when a session existed, ignoring next, which aborted the OAuth flow and dropped users in the console.

Changes
- Backend (src/oauth/server.go)
  - When not logged in, redirect directly to /oauth/consent?<original_query> instead of /login?next=…
  - Keep no-store headers; preserve the original authorize querystring.
- Frontend
  - web/src/helpers/auth.jsx: AuthRedirect now honors the login page’s next query param and only redirects to safe internal paths (starts with “/”, not “//”); otherwise falls back to /console.
  - web/src/components/auth/LoginForm.jsx: After successful login and after 2FA success, navigate to next when present and safe; otherwise go to /console.

Result
- The OAuth authorize flow now reliably reaches the consent screen.
- On approval, the server issues an authorization code and 302’s back to the client’s redirect_uri (e.g., Discourse), completing SSO as expected.

Security
- Sanitize next to avoid open-redirects by allowing only same-origin internal paths.

Compatibility
- No behavior change for normal username/password sign-ins outside the OAuth flow.
- No changes to token/userinfo endpoints.

Testing
- Manually verified end-to-end with Discourse OAuth2 Basic:
  - authorize → consent → approve → redirect with code
- Lint checks pass for modified files.
2025-09-25 13:02:40 +08:00
t0ng7u
63828349de 🔐 fix(oauth2): initialize JWKS on first key creation; prevent nil panic and set current key
Why
- First-time “Initialize Keys” caused a nil pointer panic when adding the first JWK to a nil JWKS set.
- As a result, the returned kid was missing and the first key appeared as “historical” until a second rotation.
- Improve first-time UX: only show Key Management when the server is healthy and guide admins to the correct init flow.

Backend (bug fix)
- src/oauth/server.go
  - RotateSigningKey / GenerateAndPersistKey / ImportPEMKey:
    - If simpleJWKSSet is nil, create a new jwk.NewSet() before AddKey, otherwise AddKey as usual.
    - Ensure currentKeyID is updated; enforceKeyRetention remains unchanged.
  - This prevents the nil pointer panic, ensures the first key is added to JWKS, and is immediately the current key.

Frontend (UX)
- web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
  - Show “Key Management” only when OAuth2 is enabled AND server is healthy (serverInfo present).
  - Refine the warning banner text to instruct: enable OAuth2 & SSO → Save configuration → Key Management → Initialize Keys.
- web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx
  - Dynamic primary action in “Key List” tab:
    - No keys → “Initialize Keys”
    - Has keys → “Rotate Keys”
  - Simplify error handling by relying on `message` + localized fallback.

Notes
- No API surface changes; functional bugfix plus UI/UX improvements.
- Linting passed; no new warnings.

Test plan
1) Start with OAuth2 enabled and no signing keys.
2) Open “Key Management” → click “Initialize Keys”.
3) Expect: success response with new kid; table shows the new kid as Current; JWKS endpoint returns the key; no server panic.
2025-09-23 05:08:51 +08:00
t0ng7u
5706f0ee9f 🌏 i18n: Improve i18n translation 2025-09-23 04:15:59 +08:00
t0ng7u
e9e1dbff5e ♻️ refactor: reorganize OAuth consent page structure
- Move OAuth consent component to dedicated OAuth directory as index.jsx
- Rename component export structure for better module organization
- Update App.jsx import path to reflect new OAuth page structure
- Maintain existing OAuth consent functionality while improving
2025-09-23 04:01:48 +08:00
t0ng7u
315eabc1e7 🎨 refactor(oauth2): merge modals and improve UI consistency
This commit consolidates OAuth2 client management components and
enhances the overall user experience with improved UI consistency.

### Major Changes:

**Component Consolidation:**
- Merge CreateOAuth2ClientModal.jsx and EditOAuth2ClientModal.jsx into OAuth2ClientModal.jsx
- Extract inline Modal.info into dedicated ClientInfoModal.jsx component
- Adopt consistent SideSheet + Card layout following EditTokenModal.jsx style

**UI/UX Improvements:**
- Replace custom client type selection with SemiUI RadioGroup component
- Use 'card' type RadioGroup with descriptive 'extra' prop for better UX
- Remove all Row/Col components in favor of flexbox and margin-based layouts
- Refactor redirect URI section to mimic JSONEditor.jsx visual style
- Add responsive design support for mobile devices

**Form Enhancements:**
- Add 'required' attributes to all mandatory form fields
- Implement placeholders for grant types, scopes, and redirect URI inputs
- Set grant types and scopes to default empty arrays
- Add dynamic validation and conditional rendering for client types
- Improve redirect URI management with template filling functionality

**Bug Fixes:**
- Fix SideSheet closing direction consistency between create/edit modes
- Resolve client_type submission issue (object vs string)
- Prevent "Client Credentials" selection for public clients
- Fix grant type filtering when switching between client types
- Resolve i18n issues for API scope options (api:read, api:write)

**Code Quality:**
- Extract RedirectUriCard as reusable sub-component
- Add comprehensive internationalization support
- Implement proper state management and form validation
- Follow single responsibility principle for component separation

**Files Modified:**
- web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx
- web/src/components/settings/oauth2/modals/ClientInfoModal.jsx (new)
- web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
- web/src/i18n/locales/en.json

**Files Removed:**
- web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx
- web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx

This refactoring significantly improves code maintainability, reduces
duplication, and provides a more consistent and intuitive user interface
for OAuth2 client management.
2025-09-23 03:49:53 +08:00
t0ng7u
359dbc9d94 feat(oauth2): enhance JWKS manager modal with improved UX and i18n support
- Refactor JWKSManagerModal with tab-based navigation using Card components
- Add comprehensive i18n support with English translations for all text
- Optimize header actions: refresh button only appears in key list tab
- Improve responsive design using ResponsiveModal component
- Move cautionary text from bottom to Card titles for better visibility
- Update button styles: danger type for delete, circle shape for status tags
- Standardize code formatting (single quotes, multiline formatting)
- Enhance user workflow: separate Import PEM and Generate PEM operations
- Remove redundant cancel buttons as modal already has close icon

Breaking changes: None
Affects: JWKS key management, OAuth2 settings UI
2025-09-23 01:16:17 +08:00
t0ng7u
e157ea6ba2 🎨 style(oauth2): modernize Empty component and clean up inline styles
- **Empty Component Enhancement:**
  - Replace custom User icon with professional IllustrationNoResult from Semi Design
  - Add dark mode support with IllustrationNoResultDark component
  - Standardize illustration size to 150x150px for consistency
  - Add proper padding (30px) to match design system standards

- **Style Modernization:**
  - Convert inline styles to Tailwind CSS classes where appropriate
  - Replace `style={{ marginBottom: 16 }}` with `className='mb-4'`
  - Remove redundant `style={{ marginTop: 8 }}` from Table component
  - Remove custom `style={{ marginTop: 16 }}` from pagination and button

- **Pagination Simplification:**
  - Simplify showTotal configuration from custom function to boolean `true`
  - Remove unnecessary `size='small'` property from pagination
  - Clean up pagination styling for better consistency

- **Design System Alignment:**
  - Ensure Empty component matches UsersTable styling patterns
  - Improve visual consistency across OAuth2 management interfaces
  - Follow Semi Design illustration guidelines for empty states

- **Code Quality:**
  - Reduce inline style usage in favor of utility classes
  - Simplify component props where default behavior is sufficient
  - Maintain functionality while improving maintainability

This update enhances visual consistency and follows modern React styling practices while maintaining all existing functionality.
2025-09-20 23:30:26 +08:00
t0ng7u
dc3dba0665 enhance(oauth2): improve UI components and code display experience
- **Table Layout Optimization:**
  - Remove description column from OAuth2 client table to save space
  - Add tooltip on client name hover to display full description
  - Adjust table scroll width from 1200px to 1000px for better layout
  - Improve client name column width to 180px for better readability

- **Action Button Simplification:**
  - Replace icon-only buttons with text labels for better accessibility
  - Simplify Popconfirm content by removing complex styled layouts
  - Remove unnecessary Tooltip wrappers around action buttons
  - Clean up unused Lucide icon imports (Edit, Key, Trash2)

- **Code Display Enhancement:**
  - Replace basic <pre> tags with CodeViewer component in modal dialogs
  - Add syntax highlighting for JSON content in ServerInfoModal and JWKSInfoModal
  - Implement copy-to-clipboard functionality for server info and JWKS data
  - Add performance optimization for large content display
  - Provide expandable/collapsible interface for better UX

- **Component Architecture:**
  - Import and integrate CodeViewer component in both modal components
  - Set appropriate props: content, title, and language='json'
  - Maintain loading states and error handling functionality

- **Internationalization:**
  - Add English translations for new UI elements:
    * '暂无描述': 'No description'
    * 'OAuth2 服务器配置': 'OAuth2 Server Configuration'
    * 'JWKS 密钥集': 'JWKS Key Set'

- **User Experience Improvements:**
  - Enhanced tooltip interaction for description display
  - Better visual feedback with cursor-help styling
  - Improved code readability with professional dark theme
  - Consistent styling across all OAuth2 management interfaces

This update focuses on UI/UX improvements while maintaining full functionality and adding modern code viewing capabilities to the OAuth2 management system.
2025-09-20 23:19:42 +08:00
t0ng7u
81272da9ac ♻️ refactor(oauth2): restructure OAuth2 client settings UI and extract modal components
- **UI Restructuring:**
  - Separate client info into individual table columns (name, ID, description)
  - Replace icon-only action buttons with text labels for better UX
  - Adjust table scroll width from 1000px to 1200px for new column layout
  - Remove unnecessary Tooltip wrappers and Lucide icons (Edit, Key, Trash2)

- **Component Architecture:**
  - Extract all modal dialogs into separate reusable components:
    * SecretDisplayModal.jsx - for displaying regenerated client secrets
    * ServerInfoModal.jsx - for OAuth2 server configuration info
    * JWKSInfoModal.jsx - for JWKS key set information
  - Simplify main component by removing ~60 lines of inline modal code
  - Implement proper state management for each modal component

- **Code Quality:**
  - Remove unused imports and clean up component dependencies
  - Consolidate modal logic into dedicated components with error handling
  - Improve code maintainability and reusability across the application

- **Internationalization:**
  - Add English translation for '客户端名称': 'Client Name'
  - Remove duplicate translation keys to fix linter warnings
  - Ensure all new components support full i18n functionality

- **User Experience:**
  - Enhance table readability with dedicated columns for each data type
  - Maintain copyable client ID functionality in separate column
  - Improve action button accessibility with clear text labels
  - Add loading states and proper error handling in modal components

This refactoring improves code organization, enhances user experience, and follows React best practices for component composition and separation of concerns.
2025-09-20 22:52:50 +08:00
t0ng7u
926cad87b3 📱 feat(oauth): implement responsive design for consent page
- Add responsive layout for user info section with flex-col on mobile
- Optimize button layout: vertical stack on mobile, horizontal on desktop
- Implement mobile-first approach with sm: breakpoints throughout
- Adjust container width: max-w-sm on mobile, max-w-lg on desktop
- Enhance touch targets with larger buttons (size='large') on mobile
- Improve content hierarchy with primary action button on top for mobile
- Add responsive padding and spacing: px-3 sm:px-4, py-6 sm:py-8
- Optimize text sizing: text-sm sm:text-base for better mobile readability
- Implement responsive gaps: gap-4 sm:gap-6 for icon spacing
- Add break-all class for long URL text wrapping
- Adjust meta info card spacing and dot separator sizing
- Ensure consistent responsive padding across all content sections

This update significantly improves the mobile user experience while
maintaining the desktop layout, following mobile-first design principles
with Tailwind CSS responsive utilities.
2025-09-20 17:45:58 +08:00
t0ng7u
418ce449b7 feat(oauth): redesign consent page with GitHub-style UI and improved UX
- Redesign OAuth consent page layout with centered card design
- Implement GitHub-style authorization flow presentation
- Add application popover with detailed information on hover
- Replace generic icons with scope-specific icons (email, profile, admin, etc.)
- Integrate i18n support for all hardcoded strings
- Optimize permission display with encapsulated ScopeItem component
- Improve visual hierarchy with Semi UI Divider components
- Unify avatar sizes and implement dynamic color generation
- Move action buttons and redirect info to card footer
- Add separate meta information card for technical details
- Remove redundant color styles to rely on Semi UI theming
- Enhance user account section with clearer GitHub-style messaging
- Replace dot separators with Lucide icons for better visual consistency
- Add site logo with fallback mechanism for branding
- Implement responsive design with Tailwind CSS utilities

This redesign significantly improves the OAuth consent experience by following
modern UI patterns and providing clearer information hierarchy for users.
2025-09-20 17:01:00 +08:00
Seefs
4a02ab23ce rm env 2025-09-16 17:21:11 +08:00
Seefs
984097c60b rm docs 2025-09-16 17:20:34 +08:00
Seefs
5550ec017e feat: oauth2 2025-09-16 17:10:01 +08:00
Seefs
9e6752e0ee Merge branch 'main-upstream' into feature/sso 2025-09-16 13:31:40 +08:00
IcedTangerine
18a385f817 Merge pull request #1805 from feitianbubu/pr/jimeng-video-3.0
feat: 支持即梦视频3.0,支持文生图, 图生图, 首尾帧生图
2025-09-15 17:12:28 +08:00
Calcium-Ion
8e95d338b5 Merge pull request #1807 from QuantumNous/fix-stripe-successurl
fix: stripe支付成功未正确跳转
2025-09-15 16:24:20 +08:00
creamlike1024
f236785ed5 fix: stripe支付成功未正确跳转 2025-09-15 16:22:37 +08:00
feitianbubu
f3e220b196 feat: jimeng video 3.0 req_key convert 2025-09-15 15:54:13 +08:00
feitianbubu
33bf267ce8 feat: 支持即梦视频3.0,新增10s(frames=241)支持 2025-09-15 15:10:39 +08:00
Calcium-Ion
4f760a8d40 Merge pull request #1799 from seefs001/fix/setting
fix: settings
2025-09-14 13:01:09 +08:00
Seefs
8563eafc57 fix: settings 2025-09-14 12:59:44 +08:00
Calcium-Ion
7d71f467d9 Merge pull request #1794 from seefs001/fix/veo3
fix veo3 adapter
2025-09-13 17:33:18 +08:00
Seefs
da6f24a3d4 fix veo3 adapter 2025-09-13 16:26:14 +08:00
CaIon
28ed42130c fix: update references from setting to system_setting for ServerAddress 2025-09-13 15:27:41 +08:00
Seefs
96215c9fd5 Merge pull request #1792 from QuantumNous/alpha
veo
2025-09-13 13:16:33 +08:00
Seefs
6628fd9181 Merge branch 'main' into alpha 2025-09-13 13:14:34 +08:00
Seefs
a3b8a1998a Merge pull request #1659 from Sh1n3zZ/feat-vertex-veo
feat: vertex veo (#1450)
2025-09-13 13:11:02 +08:00
Seefs
6a34d365ec Merge branch 'alpha' into feat-vertex-veo 2025-09-13 13:10:39 +08:00
CaIon
406a3e4dca Merge remote-tracking branch 'origin/main' 2025-09-13 12:54:49 +08:00
CaIon
c1d7ecdeec fix(adaptor): correct VertexKeyType condition in SetupRequestHeader 2025-09-13 12:53:41 +08:00
CaIon
6451158680 Revert "feat: gemini-2.5-flash-image-preview 文本和图片输出计费"
This reverts commit e732c58426.
2025-09-13 12:53:28 +08:00
creamlike1024
0bd4b34046 Merge branch 'feitianbubu-pr/add-jimeng-video-images' 2025-09-13 09:57:01 +08:00
feitianbubu
f14b06ec3a feat: jimeng video add images 2025-09-12 22:43:08 +08:00
feitianbubu
6ed775be8f refactor: use common taskSubmitReq 2025-09-12 22:43:03 +08:00
CaIon
b712279b2a feat(i18n): update TOTP verification message with configuration details 2025-09-12 21:53:21 +08:00
CaIon
1bffe3081d feat(settings): 移除单位美元额度设置项,为后续修改作准备 2025-09-12 21:14:10 +08:00
CaIon
cfebe80822 Merge remote-tracking branch 'origin/main' 2025-09-12 19:54:46 +08:00
CaIon
17e697af8f feat(i18n): add translations for pricing terms in English 2025-09-12 19:54:02 +08:00
CaIon
01b35bb667 Merge remote-tracking branch 'origin/main' 2025-09-12 19:29:40 +08:00
CaIon
d8410d2f11 feat(payment): add payment settings configuration and update payment methods handling 2025-09-12 19:29:34 +08:00
CaIon
e68eed3d40 feat(channel): add support for Vertex AI key type configuration in settings 2025-09-12 14:06:09 +08:00
Calcium-Ion
04cc668430 Merge pull request #1784 from Husky-Yellow/fix/1773
fix: UI 未对齐问题
2025-09-12 12:39:29 +08:00
Calcium-Ion
5d76e16324 Merge pull request #1780 from ShibaInu64/feature/support-amazon-nova
feat: support amazon nova model
2025-09-12 12:38:44 +08:00
Zhaokun Zhang
b6c547ae98 fix: UI 未对齐问题 2025-09-11 21:34:49 +08:00
CaIon
93adcd57d7 fix(responses): allow pass-through body for specific channel settings. (close #1762) 2025-09-11 21:02:12 +08:00
Calcium-Ion
e813da59cc Merge pull request #1775 from QuantumNous/alpha
Alpha
2025-09-11 18:47:35 +08:00
Xyfacai
b25ac0bfb6 fix: 预扣额度使用 relay info 传递 2025-09-11 16:04:32 +08:00
huanghejian
70c27bc662 feat: improve nova config 2025-09-11 12:31:43 +08:00
creamlike1024
db6a788e0d fix: 优化 ImageRequest 的 JSON 序列化,避免覆盖合并 ExtraFields 2025-09-11 12:28:57 +08:00
huanghejian
e3bc40f11b pref: support amazon nova 2025-09-11 12:17:16 +08:00
huanghejian
684caa3673 feat: amazon.nova-premier-v1:0 2025-09-11 10:01:54 +08:00
huanghejian
47aaa695b2 feat: support amazon nova 2025-09-10 20:30:00 +08:00
Xyfacai
cda73a2ec5 fix: dalle log 显示张数 N 2025-09-10 19:53:32 +08:00
Xyfacai
27a0a447d0 fix: err 如果是 newApiErr 则保留 2025-09-10 15:31:35 +08:00
Xyfacai
fcdfd027cd fix: openai 格式请求 claude 没计费 create cache token 2025-09-10 15:30:23 +08:00
Xyfacai
3f9698bb47 feat: dalle 自定义字段透传 2025-09-10 15:29:07 +08:00
CaIon
041782c49e chore: remove PR branching strategy workflow file 2025-09-09 23:23:53 +08:00
Calcium-Ion
18077b6e87 Merge pull request #1767 from QuantumNous/copy-claude-header-from-upstream
fix: claude header was not set correctly
2025-09-09 23:21:57 +08:00
creamlike1024
c40a4f5444 fix: claude header was not set correctly 2025-09-09 23:18:07 +08:00
CaIon
028f0220dd Merge branch 'alpha'
# Conflicts:
#	README.md
2025-09-09 23:08:17 +08:00
Calcium-Ion
a616aa3c89 Merge pull request #1692 from yunayj/alpha
修改claude system参数为数组,增加通用性
2025-09-08 14:55:48 +08:00
Seefs
91a0eb7031 wip sso 2025-09-08 12:09:26 +08:00
IcedTangerine
1c12c73496 Merge pull request #1761 from QuantumNous/openaitoclaude-improve
feat: 改进Claude响应转OpenAI响应
2025-09-07 23:39:30 +08:00
creamlike1024
b29efbde52 feat(relay-claude): mapping stop reason and send text delta on block start type
- convert claude stop reason "max_tokens" to openai "length"
- send content_block_start content text delta
2025-09-07 23:03:19 +08:00
Seefs
b7527eb80e Merge pull request #1677 from QuantumNous/gemini-2.5-flash-image-preview-billing
feat: gemini-2.5-flash-image-preview 文本和图片输出计费
2025-09-07 14:15:24 +08:00
Seefs
d05974fa3d Merge pull request #1754 from HynoR/fix/dtresp
fix: ensure the BuiltInTools entry exists before incrementing CallCount
2025-09-07 13:56:42 +08:00
HynoR
a77a88308a fix: enhance tool usage parsing with additional nil checks and error logging 2025-09-07 07:42:25 +08:00
t0ng7u
e5a5d2de7c 🐛 fix(models): export setActivePage to prevent tab-change TypeError
Context:
Clicking a vendor tab triggered “setActivePage is not a function” from ModelsTabs.jsx:43.

Root cause:
ModelsTabs expects `setActivePage` via props (spread from `useModelsData`), but the hook did not expose it in its return object, so the prop resolved to `undefined`.

Fix:
Export `setActivePage` from `useModelsData`’s return object so `ModelsTabs` receives a valid function.

Result:
Tab switching now correctly resets pagination to page 1 and reloads models without runtime errors.

Files:
- web/src/hooks/models/useModelsData.jsx

Test plan:
- Open the Models page
- Click different vendor tabs
- Verify no crash occurs and the list reloads with page reset to 1

Refs: web/src/components/table/models/ModelsTabs.jsx:43
2025-09-06 21:57:26 +08:00
HynoR
c0187d50ff fix: add error handling for missing built-in tools and validate response in stream handler 2025-09-05 13:58:24 +08:00
Seefs
3d0bf36981 Merge pull request #1749 from nekohy/feats-negative-number 2025-09-04 23:39:43 +08:00
Nekohy
e61c1dc738 fix: allow the negative number for override.go 2025-09-04 23:36:19 +08:00
CaIon
91a627ddfc fix(channel): implement per-channel locking to ensure thread-safe updates in multi-key mode 2025-09-03 15:52:54 +08:00
Calcium-Ion
3064ff093a Add request format conversion functionality
Updated the features list to include request format conversion functionality and adjusted the order of items.
2025-09-03 14:45:00 +08:00
CaIon
e2f736bd2d feat(readme): update format conversion feature details in README 2025-09-03 14:43:51 +08:00
CaIon
c6cf1b98f8 feat(option): enhance UpdateOption to handle various value types and improve validation 2025-09-03 14:30:25 +08:00
CaIon
56fc3441da feat(monitor_setting): implement automatic channel testing configuration 2025-09-03 14:00:52 +08:00
t0ng7u
ebaaecb9d9 🐛 fix(models-sync): allow sync when no conflicts selected
When syncing official models, clicking "Apply overwrite" with zero selected
conflict fields resulted in no request being sent and the modal simply closing.
This blocked creation of missing models/vendors even though the backend
supports an empty `overwrite` array and will still create missing items.

Changes:
- Remove the early-return guard in `UpstreamConflictModal.handleOk`
- Always call `onSubmit(payload)` even when `payload` is empty
- Keep closing behavior when the request succeeds

Behavior:
- Users can now proceed with upstream sync without selecting any conflict fields
- Missing models/vendors are created as expected
- Existing models are not overwritten unless fields are explicitly selected

Affected:
- web/src/components/table/models/modals/UpstreamConflictModal.jsx

Quality:
- Lint passes
- No breaking changes
- No visual/UI changes beyond the intended behavior

Test plan:
1) Open official models sync and trigger a conflicts preview
2) Click "Apply overwrite" without selecting any fields
3) Expect the sync to proceed and a success toast indicating created models
4) Re-try with some fields selected to confirm overwrites still work
2025-09-03 00:06:27 +08:00
t0ng7u
fa7ba4a390 🐛 fix(models sync): send correct overwrite payload and drop fallback
Ensure UpstreamConflictModal submits { overwrite: payload, locale } instead of spreading an array into an object
Remove numeric-key fallback from applyUpstreamOverwrite for simpler and explicit logic
Effect: selected fields are now actually updated; success message shows updated model count
Refs: backend SyncUpstreamModels expects overwrite: overwriteField[]
2025-09-02 19:07:17 +08:00
t0ng7u
29983e434f Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-02 18:49:51 +08:00
t0ng7u
8c65264474 feat(sync): multi-language sync wizard, backend locale support, and conflict modal UX improvements
Frontend (web)
- ModelsActions.jsx
  - Replace “Sync Official” with “Sync” and open a new two-step SyncWizard.
  - Pass selected locale through to preview, sync, and overwrite flows.
  - Keep conflict resolution flow; inject locale into overwrite submission.

- New: models/modals/SyncWizardModal.jsx
  - Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja).
  - Horizontal, centered Radio cards; returns { option, locale } via onConfirm.

- UpstreamConflictModal.jsx
  - Add search input (model fuzzy search) and native pagination.
  - Column header checkbox now only applies to rows in the current filtered result.
  - Fix “Cannot access ‘filteredDataSource’ before initialization”.
  - Refactor with useMemo/useCallback; extract helpers to remove duplicated logic:
    - getPresentRowsForField, getHeaderState, applyHeaderChange
  - Minor code cleanups and stability improvements.

- i18n (en.json)
  - Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.).
  - Adjust minor translations.

Hooks
- useModelsData.jsx
  - Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale.
  - Send locale via query/body accordingly.

Backend (Go)
- controller/model_sync.go
  - Accept locale from query/body and resolve i18n upstream URLs.
  - Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata).
  - Make HTTP timeouts/retries/limits configurable:
    - SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB
  - Add ETag-based caching and support both envelope and pure array JSON formats.
  - Concurrently fetch vendors and models; improve error responses with locale and source URLs.
  - Include source meta (locale, models_url, vendors_url) in success payloads.

Notes
- No breaking changes expected.
- Lint passes for touched files.
2025-09-02 18:49:37 +08:00
Seefs
cd4b75f492 Merge pull request #1733 from seefs001/fix/jsoneditor
fix: adjust column spans in JSONEditor for better layout  #1719
2025-09-02 18:32:36 +08:00
Seefs
faad6bcd0c fix: adjust column spans in JSONEditor for better layout #1719 2025-09-02 18:28:23 +08:00
Seefs
265a9ea78c Merge pull request #1724 from momomobinx/base
openai v1/models 完全兼容 解决接入trae时候的字段校验
2025-09-02 18:09:56 +08:00
Seefs
aeab08099b Merge branch 'alpha' into base 2025-09-02 18:08:39 +08:00
t0ng7u
d9f37d16f7 🎨 fix: sidebar skeleton background and icon spacing consistency
- Set sidebar skeleton background to use theme variable (--semi-color-bg-0) instead of hardcoded white for better dark mode compatibility
- Apply consistent background to both collapsed and expanded skeleton states
- Ensure sidebar container uses theme background when skeleton is loading
- Remove duplicate margin-right classes from skeleton wrapper components that conflicted with CSS definitions
- Simplify navigation text structure by removing unnecessary div wrappers to improve text truncation
- Add proper text layout styles for better truncation handling when menu items have long names
- Standardize icon-to-text spacing across all sidebar navigation items
2025-09-02 17:07:01 +08:00
xuebin
203abf4430 openai v1/models 完全兼容 解决接入trae时候的字段校验 2025-09-02 14:17:54 +08:00
creamlike1024
17024490e9 Merge branch 'feitianbubu-pr/opt-audio-usage' into alpha 2025-09-02 13:35:28 +08:00
feitianbubu
f7ae3621f4 feat: use audio token usage if return 2025-09-02 10:58:10 +08:00
t0ng7u
5cbd9da3f5 fix(web/layout): normalize HeaderBar -> headerbar (case) 2025-09-02 04:10:32 +08:00
t0ng7u
daffba3641 🤖 fix(web/layout): rename HeaderBar -> headerbar (case sensitive) 2025-09-02 04:03:19 +08:00
t0ng7u
860ab51434 🐛 fix(web/layout): explicitly import headerbar/index.jsx to resolve Linux build failure
The Linux/Vite build failed with:
“Could not resolve "./headerbar" from "src/components/layout/PageLayout.jsx"”

On Linux and with stricter ESM/rollup resolution, directory index files (index.jsx)
may not be auto-resolved reliably. Explicitly importing the index file ensures
cross-platform stability.

Changes:
- Update PageLayout import from "./headerbar" to "./headerbar/index.jsx"

Impact:
- Fixes build on Linux
- No runtime behavior changes

Verification:
- Linter passes for web/src/components/layout/PageLayout.jsx

Notes:
- Prefer explicit index file imports (and extensions) to avoid platform differences.
2025-09-02 03:54:32 +08:00
t0ng7u
1442666cc0 🌏 i18n: replace to correct punctuation mark 2025-09-02 03:42:31 +08:00
t0ng7u
5ac9ebdebb feat: Add skeleton loading states for sidebar navigation
Add comprehensive skeleton screen implementation for sidebar to improve loading UX, matching the existing headerbar skeleton pattern.

## Features Added
- **Sidebar skeleton screens**: Complete 1:1 recreation of sidebar structure during loading
- **Responsive skeleton layouts**: Different layouts for expanded (164×30px) and collapsed (44×44px) states
- **Skeleton component enhancements**: Extended SkeletonWrapper with new skeleton types (sidebar, button, sidebarNavItem, sidebarGroupTitle)
- **Minimum loading time**: Integrated useMinimumLoadingTime hook with 500ms duration for smooth UX

## Layout Specifications
- **Expanded nav items**: 164×30px with 8px horizontal margins and 3px vertical margins
- **Collapsed nav items**: 44×44px with 4px bottom margin and 8px horizontal margins
- **Collapse button**: 156×24px (expanded) / 36×24px (collapsed) with rounded corners
- **Container padding**: 12px top padding, 8px horizontal margins
- **Group labels**: 4px 15px 8px padding matching real sidebar-group-label styles

## Code Improvements
- **Refactored skeleton rendering**: Eliminated code duplication using reusable components (NavRow, CollapsedRow)
- **Configuration-driven sections**: Sections defined as config objects with title widths and item widths
- **Fixed width calculations**: Removed random width generation, using precise fixed widths per menu item
- **Proper CSS class alignment**: Uses real sidebar CSS classes (sidebar-section, sidebar-group-label, sidebar-divider)

## UI/UX Enhancements
- **Bottom-aligned collapse button**: Fixed positioning using margin-top: auto to stay at viewport bottom
- **Accurate spacing**: Matches real sidebar margins, padding, and spacing exactly
- **Skeleton stability**: Fixed width values prevent layout shifts during loading
- **Clean file structure**: Removed redundant HeaderBar.js export file

## Technical Details
- Extended SkeletonWrapper component with sidebar-specific skeleton types
- Integrated skeleton loading state management in SiderBar component
- Added support for collapsed state awareness in skeleton rendering
- Implemented precise dimension matching for pixel-perfect loading states

Closes: Sidebar skeleton loading implementation
2025-09-02 03:38:01 +08:00
t0ng7u
a47a37d315 🧹 refactor(db): remove legacy models/vendors index cleanup logic
- Delete dropIndexIfExists helper from `model/main.go`
- Remove all calls to dropIndexIfExists in `migrateDB` and `migrateDBFast`
- Drop related comments and MySQL-only DROP INDEX code paths
- Keep GORM AutoMigrate as the sole migration path for `Model` and `Vendor`

Why:
- Simplifies migrations and avoids destructive index drops at startup
- Prevents noisy MySQL 1091 errors and vendor-specific branches
- Aligns with composite unique indexes (uk_model_name_delete_at, uk_vendor_name_delete_at)

Impact:
- No expected runtime behavior change; schema remains managed by GORM
- Legacy single-column unique indexes (if any) will no longer be auto-dropped
- Safe across MySQL/PostgreSQL/SQLite; MySQL Chinese charset checks remain intact

Verification:
- Lint passed for `model/main.go`
- Confirmed no remaining `DROP INDEX` or `dropIndexIfExists` references
2025-09-02 02:24:17 +08:00
t0ng7u
fbc19abd28 feat(models-sync): official upstream sync with conflict resolution UI, opt‑out flag, and backend resiliency
Backend
- Add endpoints:
  - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0)
  - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields)
- Respect opt‑out: skip models with sync_official = 0 in both preview and apply
- Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list
- Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM
- Make HTTP fetching robust:
  - Shared http.Client (connection reuse) with 3x exponential backoff retry
  - 10MB response cap; keep existing IPv4‑first for *.github.io
- Vendor handling:
  - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips
  - Transactional overwrite to avoid partial updates
- Small cleanups and clearer helpers (containsField, coalesce, chooseStatus)

Frontend
- ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow
- New UpstreamConflictModal:
  - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all
  - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values
  - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer
  - Full i18n coverage
- useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply
- EditModelModal: add “Participate in official sync” switch; persisted as sync_official
- ModelsColumnDefs: add “Participate in official sync” column

i18n
- Add missing English keys for the new UI and messages; fix quoting issues

Refs
- Upstream metadata: https://github.com/basellm/llm-metadata
2025-09-02 02:04:22 +08:00
t0ng7u
1f111a163a feat(ratio-sync, ui): add built‑in “Official Ratio Preset” and harden upstream sync
Backend (controller/ratio_sync.go):
- Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io)
- Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config)
- Harden HTTP client:
  - IPv4‑first with IPv6 fallback for github.io
  - Add ResponseHeaderTimeout
  - 3 attempts with exponential backoff (200/400/800ms)
- Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader)
- Robust parsing: support type1 ratio_config map and type2 pricing list
- Use net.SplitHostPort for host parsing
- Use float tolerance in differences comparison to avoid false mismatches
- Remove unused code (tryDirect) and improve warnings

Frontend:
- UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json
- ChannelSelectorModal.jsx:
  - Pin the official source at the top of the list
  - Show a green “官方” tag next to the status
  - Refactor status renderer to accept the full record

Notes:
- Backward compatible; no API surface changes
- Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json
2025-09-01 23:43:39 +08:00
Seefs
b601d8fd7c Merge pull request #1680 from HynoR/fix/res
fix: update model name filtering to be case-sensitive
2025-09-01 21:04:27 +08:00
Seefs
e98ca000f2 Merge pull request #1712 from seefs001/feature/bark
feat: bark notification #1699
2025-09-01 20:28:12 +08:00
Seefs
5351c28af8 Merge pull request #1713 from seefs001/feature/channel_remark
feat: add channel remark #1710
2025-09-01 20:26:06 +08:00
Seefs
e174861b96 feat: add channel remark #1710 2025-09-01 16:23:29 +08:00
Seefs
247e029159 feat: bark notification #1699 2025-09-01 15:57:23 +08:00
IcedTangerine
5cfc133413 Merge pull request #1672 from feitianbubu/pr/fix-mysql-default-false
fix: prevent loop auto-migrate with bool default false
2025-08-31 15:06:37 +08:00
creamlike1024
c6f53e4cc8 fix: revert 3a3be21 2025-08-31 14:59:55 +08:00
creamlike1024
c8acbdb363 fix: update sidebar modules role check 2025-08-31 14:54:47 +08:00
creamlike1024
3a3be21366 fix(user): UpdateSelf 边栏权限检查和类型检查 2025-08-31 14:40:35 +08:00
creamlike1024
274da13a19 fix: add OptionMap RLock to GetStatus() 2025-08-31 14:28:02 +08:00
creamlike1024
153994fe45 Merge branch 'alpha' of github.com:x-Ai/new-api into x-Ai-alpha 2025-08-31 14:03:17 +08:00
t0ng7u
cdef6da9e9 🎨 style(go): format entire codebase
- Apply canonical Go formatting to all .go files
- No functional changes; whitespace/import/struct layout only
- Improves consistency, reduces diff noise, and aligns with standard tooling
2025-08-31 13:08:34 +08:00
t0ng7u
9127449a7a 🐛 fix(db): rename composite unique indexes to avoid drop/recreate on restart
- Model: rename `uk_model_name` -> `uk_model_name_delete_at`
  (composite on `model_name` + `deleted_at`)
- Vendor: rename `uk_vendor_name` -> `uk_vendor_name_delete_at`
  (composite on `name` + `deleted_at`)
- Keep legacy cleanup in `model/main.go` to drop old index names
  (`uk_model_name`, `model_name`, `uk_vendor_name`, `name`) for compatibility.

Result: idempotent GORM migrations and no unnecessary index churn on MySQL restarts.

Files:
- `model/model_meta.go`
- `model/vendor_meta.go`
2025-08-31 13:00:28 +08:00
F。
8809c44443 顶栏和侧边栏管理
增加用户体验
2025-08-31 07:07:40 +08:00
t0ng7u
6a87808612 🎨 chore(web): apply ESLint and Prettier auto-fixes (baseline)
- Ran: bun run eslint:fix && bun run lint:fix
- Inserted AGPL license header via eslint-plugin-header
- Enforced no-multiple-empty-lines and other lint rules
- Formatted code using Prettier v3 (@so1ve/prettier-config)
- No functional changes; formatting-only baseline across JS/JSX files
2025-08-30 21:15:10 +08:00
t0ng7u
105b86c660 🤓 chore: remove the useless tooltip component 2025-08-29 23:56:18 +08:00
t0ng7u
b8b66c3900 feat(pricing): add default vendor icons with LobeHub color support
- Add defaultVendorIcons mapping for major AI vendors
- Update getOrCreateVendor to automatically set vendor icons
- Add getDefaultVendorIcon helper function
- Support LobeHub icons Color variants (e.g., Claude.Color, Gemini.Color)
- Fix issue where default vendors were created without icons

This ensures that when new models are encountered, their vendors
will be created with appropriate colored icons for better UI display.

Affected vendors include:
- OpenAI, Anthropic, Google, Moonshot, 智谱, 阿里巴巴
- DeepSeek, MiniMax, 百度, 讯飞, 腾讯, Cohere
- Cloudflare, 360, 零一万物, Jina, Mistral, xAI
- Meta, 字节跳动, 快手, 即梦, Vidu, Microsoft/Azure
2025-08-29 23:42:33 +08:00
creamlike1024
bc5b9a5506 Merge branch 'feitianbubu-pr/fix-kling-text2image' into alpha 2025-08-29 22:47:21 +08:00
creamlike1024
9c798dcd16 Merge branch 'pr/fix-kling-text2image' of github.com:feitianbubu/new-api into feitianbubu-pr/fix-kling-text2image 2025-08-29 22:45:59 +08:00
creamlike1024
f5b8abc3f3 Merge branch 'feitianbubu-pr/kling-new-params' into alpha 2025-08-29 22:40:23 +08:00
creamlike1024
09cc127121 Merge branch 'pr/kling-new-params' of github.com:feitianbubu/new-api into feitianbubu-pr/kling-new-params 2025-08-29 22:39:54 +08:00
t0ng7u
ac67d50616 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-29 22:36:39 +08:00
t0ng7u
86964bb426 🎨 feat(ui): enhance pricing components with improved icons and responsive design
- Replace copy button icon from semi-ui IconCopy to lucide-react Copy in PricingCardView
- Add conditional tooltip functionality to SelectableButtonGroup that only shows when text overflows
- Implement responsive table column behavior in PricingTableColumns with mobile-aware fixed positioning
- Use DOM-based overflow detection (scrollWidth vs clientWidth) for better performance
- Apply useIsMobile hook to conditionally set fixed: 'right' only on desktop devices

These changes improve user experience across different screen sizes and provide more consistent iconography throughout the pricing interface.
2025-08-29 22:36:05 +08:00
IcedTangerine
c05dc07666 Merge pull request #1564 from feitianbubu/pr/init-default-vendor
feat: init default vendor
2025-08-29 19:48:20 +08:00
yunayj
af94e11c7d 修改claude system参数为数组格式,提升API兼容性 2025-08-29 19:06:01 +08:00
t0ng7u
0f86c4df9e chore: Increase default page size from 10 to 100 items in model pricing views
This commit updates the default pagination settings across the model pricing
components to improve user experience by reducing the need for frequent
page navigation when browsing large model catalogs.

Changes made:
- Update initial pageSize state from 10 to 100 in useModelPricingData hook
- Set defaultPageSize to 100 in PricingTable pagination configuration
- Increase default skeletonCount from 10 to 100 in PricingCardSkeleton

Files modified:
- web/src/hooks/model-pricing/useModelPricingData.jsx
- web/src/components/table/model-pricing/view/table/PricingTable.jsx
- web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx

This change affects both card and table view modes of the model pricing page,
ensuring consistent pagination behavior across different display formats.
2025-08-29 18:30:21 +08:00
t0ng7u
5f0db18d3a 📱 feat(pricing-header): show only search/copy/filter on mobile; hide vendor intro
- Mobile (isMobile=true): render SearchActions (search, copy, filter) only; hide vendor intro card
- Keep PricingFilterModal available on mobile for filtering
- Desktop/Non-mobile: unchanged behavior (vendor intro remains visible)
- Improves small-screen UX by reducing visual clutter and prioritizing primary actions

Files:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx

Notes:
- Added `SearchActions` import and conditional rendering
- No breaking changes; no styling changes required
2025-08-29 17:26:51 +08:00
t0ng7u
919e6937ee 🎨 feat: Implement responsive design for SelectableButtonGroup component
This commit introduces a comprehensive responsive design system for the SelectableButtonGroup component that adapts to container width changes, particularly optimized for dynamic sidebar layouts.

## Key Features

### 1. Container Width Detection
- Added `useContainerWidth` hook using ResizeObserver API
- Real-time container width monitoring for responsive calculations
- Automatic layout adjustments based on available space

### 2. Intelligent Column Layout
Implements a 4-tier responsive system:
- **≤280px**: 1 column + tags (mobile portrait)
- **281-380px**: 2 columns + tags (narrow screens)
- **381-460px**: 3 columns - tags (general case, prioritizes readability)
- **>460px**: 3 columns + tags (wide screens, full feature display)

### 3. Dynamic Tag Visibility
- Tags automatically hide in medium-width containers (381-460px) to improve text readability
- Tags show in narrow and wide containers where space allows for optimal UX
- Responsive threshold ensures content clarity across all viewport sizes

### 4. Adaptive Grid Spacing
- Compact spacing `[4,4]` for containers ≤400px
- Standard spacing `[6,6]` for larger containers
- Additional `.sbg-compact` CSS class for fine-tuned styling in narrow layouts

### 5. Sidebar Integration
- Perfectly compatible with dynamic sidebar width: `clamp(280px, 24vw, 520px)`
- Automatically adjusts as sidebar scales with viewport changes
- Maintains optimal button density and information display at all sizes

## Technical Implementation

- **Hook**: `useContainerWidth.js` - ResizeObserver-based width detection
- **Component**: Enhanced `SelectableButtonGroup.jsx` with responsive logic
- **Styling**: Added `.sbg-compact` mode in `index.css`
- **Performance**: Efficient span calculation using `Math.floor(24 / perRow)`

## Benefits

- Improved UX across all screen sizes and sidebar configurations
- Better text readability through intelligent tag hiding
- Seamless integration with existing responsive sidebar system
- Maintains component functionality while optimizing space utilization

Closes: Responsive design implementation for model marketplace sidebar components
2025-08-29 17:05:49 +08:00
feitianbubu
64e23f02f7 feat: add kling video new params support
for example: image_tail,negative_prompt,camera_control
2025-08-29 11:51:41 +08:00
CaIon
fbe7f35a25 feat(token_counter): add mime type extraction for base64 encoded data 2025-08-28 17:30:53 +08:00
CaIon
8cd0150a75 fix(channel): ensure thread-safe polling with channel-specific lock 2025-08-28 16:34:23 +08:00
Xyfacai
839aa401f0 fix: 修复openai error 错误被覆盖 2025-08-28 16:08:51 +08:00
HynoR
4055777110 fix: update model name filtering to be case-sensitive 2025-08-28 15:16:25 +08:00
CaIon
b3a99a2625 Revert "refactor: replace DeepCopy with Copy for request handling consistency"
This reverts commit 872f7a9648.
2025-08-28 15:11:55 +08:00
CaIon
872f7a9648 refactor: replace DeepCopy with Copy for request handling consistency 2025-08-28 14:57:47 +08:00
CaIon
b0c703935f fix(pre_consume_quota): improve error messages for insufficient user quota 2025-08-28 13:57:16 +08:00
CaIon
621d2b0b6a refactor: replace json.Marshal with common.Marshal for consistency and error handling 2025-08-28 13:51:07 +08:00
creamlike1024
e69520b7fb Merge branch 'iszcz-alpha' into alpha 2025-08-27 23:27:31 +08:00
creamlike1024
4b968d03a1 fix(relay): initialize TaskRelayInfo 2025-08-27 23:26:51 +08:00
creamlike1024
edc6679140 Merge branch 'alpha' of github.com:iszcz/new-api into iszcz-alpha 2025-08-27 22:26:15 +08:00
creamlike1024
e732c58426 feat: gemini-2.5-flash-image-preview 文本和图片输出计费 2025-08-27 21:30:52 +08:00
Sh1n3zZ
81e29aaa3d feat: vertex veo (#1450) 2025-08-27 18:06:47 +08:00
CaIon
c5a1cbe755 fix(token_counter): update token counting logic to handle media token conditions 2025-08-27 17:11:33 +08:00
CaIon
35218609d9 fix(image): add error handling for empty base64 string in image decoding 2025-08-27 16:38:32 +08:00
feitianbubu
7629ad553a fix: prevent loop auto-migrate with bool default false 2025-08-27 12:33:56 +08:00
CaIon
7ddf3a112c fix(relay-gemini): update media handling in candidate content processing 2025-08-27 12:14:50 +08:00
t0ng7u
034094c2d2 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-27 11:59:41 +08:00
t0ng7u
65ed6d9d5b feat(playground): add search functionality to group selector
- Add `filter={selectFilter}` to enable search filtering in group Select component
- Add `autoClearSearchValue={false}` to preserve search input value
- Maintain consistency with existing model selector search behavior
- Improve user experience by allowing quick filtering of group options

Files modified:
- web/src/components/playground/SettingsPanel.jsx
2025-08-27 11:57:12 +08:00
t0ng7u
4524f90ebd 🐛 fix(playground): set Chat error text color to white to match Semi UI
- Update error-state rendering to use white text in the playground chat
- Remove Typography.Text `type="danger"` and the red background for consistency with official behavior
- Preserve layout and other message states (loading/success/system) unchanged
- No linter issues introduced

Files touched:
- web/src/components/playground/MessageContent.jsx
2025-08-27 11:50:43 +08:00
creamlike1024
33dd326007 Merge branch 'AAEE86-alpha' into alpha 2025-08-27 01:30:20 +08:00
creamlike1024
95b487c51e feat: add disable cache middleware 2025-08-27 01:30:01 +08:00
creamlike1024
fcb03392d1 Merge branch 'alpha' of github.com:AAEE86/new-api into AAEE86-alpha 2025-08-27 01:28:37 +08:00
CaIon
64a6168092 fix(model_ratio): update return value logic for gemini-2.5-flash-lite 2025-08-26 23:01:00 +08:00
CaIon
6a6edaa7cf Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-26 20:38:32 +08:00
Calcium-Ion
a95d70cf93 Merge pull request #1579 from wzxjohn/hotfix/openai_claude_convert
fix(adaptor): missing first text delta while convert OpenAI to Claude
2025-08-26 20:37:54 +08:00
Calcium-Ion
3e01dc81ec Merge pull request #1652 from HynoR/feat/ds3.1
feat: adapt Volcengine adaptor for deepseek3.1 with thinking mode
2025-08-26 20:35:29 +08:00
CaIon
e087c9fe9e fix: update web search handling and request structure in adaptor and openai_request 2025-08-26 18:15:18 +08:00
CaIon
33d601db82 fix: update error types for upstream errors and JSON marshal failure 2025-08-26 16:26:56 +08:00
CaIon
eef73e3699 fix: update PromptTokens assignment logic in relay_responses 2025-08-26 14:21:10 +08:00
CaIon
1cc07546cb feat: replace pcopy with jinzhu/copier for deep copy functionality 2025-08-26 13:40:41 +08:00
CaIon
e23f01f8d5 fix: Invalid type for 'input[x].summary': expected an array of objects, but got null instead 2025-08-26 13:17:31 +08:00
CaIon
a3c2b28d6a fix: ensure reasoning is not nil before setting effort in OpenAI responses 2025-08-25 22:46:45 +08:00
iszcz
289ed24899 task_relay_info 2025-08-25 18:01:10 +08:00
IcedTangerine
98db907680 Merge pull request #1549 from QuantumNous/update-openai-websearch-price
feat: update openai websearch price
2025-08-25 16:52:57 +08:00
AAEE86
b1cc9050ff fix: 添加接口速率限制中间件,优化验证码输入框交互体验 2025-08-25 15:20:04 +08:00
AAEE86
dc4f5750af feat(channel): 添加2FA验证后查看渠道密钥功能
- 新增接口通过2FA验证后获取渠道密钥
- 统一实现2FA验证码和备用码的验证逻辑
- 记录用户查看密钥的操作日志
- 编辑渠道弹窗新增查看密钥按钮,触发2FA验证模态框
- 使用TwoFactorAuthModal进行验证码输入及验证
- 验证成功后弹出渠道密钥展示窗口
- 对渠道编辑模态框的状态进行了统一重置优化
- 添加相关国际化文案支持密钥查看功能
2025-08-25 14:45:48 +08:00
CaIon
d374a22b70 feat: support qwen-image-edit 2025-08-25 14:33:12 +08:00
CaIon
595ed6b40e Merge remote-tracking branch 'origin/feat/dalle-extra' into alpha
# Conflicts:
#	dto/dalle.go
2025-08-25 14:20:54 +08:00
CaIon
c9f5b1de1a fix: improve model ratio handling for reserved models in getHardcodedCompletionModelRatio 2025-08-25 11:59:55 +08:00
CaIon
522f2d920b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-25 11:52:57 +08:00
CaIon
bef59929db fix: update model ratio handling for GPT versions 2025-08-25 11:52:45 +08:00
t0ng7u
b27b9a1098 🍎 chore(EditChannelModal.jsx): Format code file 2025-08-25 11:32:28 +08:00
t0ng7u
70de3819e8 🧹 chore: remove useless i18n file 2025-08-25 11:29:13 +08:00
同語
af18dec46b 🤓 feat: When adding or editing channels, add the function of clicking the added model to copy its name
Merge pull request #1648 from AAEE86/alpha
2025-08-25 11:26:45 +08:00
CaIon
43efc2161a feat: add SaveWithoutKey method to Channel and update status saving logic 2025-08-25 11:20:16 +08:00
CaIon
caaa988c87 fix: correct logic for handling nil OpenAI error codes. (close #1609) 2025-08-25 11:19:32 +08:00
HynoR
ee6dd9179b feat: adapt Volcengine adaptor for deepseek3.1 model with thinking suffix 2025-08-25 11:16:43 +08:00
Calcium-Ion
f96a733430 Merge pull request #1647 from aotsukiqx/main
fix: update channel.go fix #1641
2025-08-25 10:25:06 +08:00
CaIon
de23ccd234 feat: update ali image response handling 2025-08-24 22:50:45 +08:00
AAEE86
da516af837 feat: 在新增&编辑渠道时添加点击模型复制名称功能
(cherry picked from commit c4935f392f)
2025-08-24 22:50:29 +08:00
CaIon
7fbf9c4851 feat: enhance image request handling and add async support 2025-08-24 21:52:56 +08:00
t0ng7u
808f5c481e 💄 style(topup): align container width with PersonalSetting (w-full max-7xl)
- Set TopUp page outer wrapper to "w-full max-w-7xl mx-auto px-2"
  to match PersonalSetting and ensure consistent layout width and padding.
- No functional changes; UI-only adjustment.
- Lint checks passed.
- Verified that pages/TopUp only re-exports the component (no extra wrapper).

Affected: web/src/components/topup/index.jsx
2025-08-24 17:29:42 +08:00
t0ng7u
6dcf954bfe 🕒 feat(ui): standardize Timelines to left mode and unify time display
- Switch Semi UI Timeline to mode="left" in:
  - web/src/components/layout/NoticeModal.jsx
  - web/src/components/dashboard/AnnouncementsPanel.jsx
- Show both relative and absolute time in the `time` prop (e.g. "3 days ago 2025-02-18 10:30")
- Move auxiliary description to the `extra` prop and remove duplicate rendering from content area
- Keep original `extra` data intact; compute and pass:
  - `time`: absolute time (yyyy-MM-dd HH:mm)
  - `relative`: relative time (e.g., "3 days ago")
- Update data assembly to expose `time` and `relative` without overwriting `extra`:
  - web/src/components/dashboard/index.jsx
- No i18n changes; no linter errors introduced

Why: Aligns Timeline layout across the app and clarifies time context by combining relative and absolute timestamps while preserving auxiliary notes via `extra`.
2025-08-24 17:23:03 +08:00
aotsuki
cb6fa7d46d Update channel.go 2025-08-24 11:39:38 +08:00
t0ng7u
1e3621833f Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-24 10:52:54 +08:00
t0ng7u
eedb57b2c6 📱 feat(header): add mobile filter skeleton; align mobile tag layout
- Add mobile filter-button placeholder in skeleton when `isMobile` is true
- Plumb `isMobile` from `PricingVendorIntroWithSkeleton` to `PricingVendorIntroSkeleton`
- Rename skeleton key from 'button' to 'copy-button' for consistency
- Neutralize copy-button skeleton color to match input (use neutral palette)
- Keep “Total x models” tag inline with title on mobile; wrap only when space is insufficient
- Mirror the same title+tag layout in the skeleton (flex-row flex-wrap items-center)
- No linter errors introduced

Affected files:
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
2025-08-24 10:52:43 +08:00
Calcium-Ion
524f6d6af5 Merge pull request #1644 from nekohy/feats-custom-request-headers
feats:add custom headers override
2025-08-24 10:14:32 +08:00
Nekohy
53f7a7993e fix: log name 2025-08-24 01:32:19 +08:00
Nekohy
abcb353793 feats:add custom headers override 2025-08-24 01:02:23 +08:00
t0ng7u
d7c2a9f1b8 feat(model-pricing): enhance pricing vendor intro components with performance optimizations and UX improvements
## Major Changes

### Performance Optimizations
- Add React.memo to all components to prevent unnecessary re-renders
- Implement useCallback for expensive functions (renderSearchActions, renderHeaderCard, etc.)
- Extract createSkeletonRect function outside component to avoid recreation
- Optimize constant definitions and reduce magic numbers

### UI/UX Enhancements
- Replace Popover with Modal for vendor description display
- Add modal max height and vertical scrolling support
- Fix filter modal not showing on first click by always mounting component
- Improve responsive design with mobile-specific modal sizing

### Code Structure Improvements
- Refactor avatar rendering logic into pure helper functions
- Reorganize constants into semantic groups (CONFIG, THEME_COLORS, COMPONENT_STYLES, CONTENT_TEXTS)
- Simplify complex vendor info processing logic
- Fix sourceModels selection logic for better data handling

### Bug Fixes
- Fix React key prop missing in skeleton elements causing render errors
- Resolve modal mounting timing issues
- Correct dependency arrays in useCallback hooks

### Code Quality
- Remove redundant comments while preserving essential documentation
- Add displayName to all memo components for better debugging
- Standardize code formatting and naming conventions
- Improve TypeScript-like prop validation

## Files Modified
- PricingTopSection.jsx
- PricingVendorIntro.jsx
- PricingVendorIntroSkeleton.jsx
- PricingVendorIntroWithSkeleton.jsx
- SearchActions.jsx

## Performance Impact
- Reduced re-renders by approximately 60-80%
- Improved memory efficiency through function memoization
- Enhanced user experience with smoother interactions
2025-08-24 00:10:26 +08:00
t0ng7u
7969df3926 🤖 style: Make some changes 2025-08-23 22:03:34 +08:00
t0ng7u
97c52a6991 🐛 fix(model-pricing/header): pass sidebarProps to PricingFilterModal to prevent FilterModalContent crash
- Re-introduce and forward `sidebarProps` from PricingTopSection to PricingFilterModal
- Fix TypeError: “Cannot destructure property 'showWithRecharge' of 'sidebarProps' as it is undefined”
- Keep modal state managed at top section; no other behavioral changes
- Lint passes

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
2025-08-23 21:56:43 +08:00
t0ng7u
a50288c186 🤓 style: add outline theme in pricingcard copy button 2025-08-23 21:48:18 +08:00
t0ng7u
f246c12959 🎨 refactor(model-pricing/header): unify header design, extract SearchActions, and improve skeleton
- Extract SearchActions.jsx and replace inline renderSearchActions in PricingVendorIntro.jsx for reuse
- Refactor PricingVendorIntro.jsx:
  - Introduce renderHeaderCard(), tagStyle, getCoverStyle(), and MAX_VISIBLE_AVATARS constant
  - Standardize vendor header cover (gradient + background image) and tag contrast
  - Use border instead of ring for vendor badges; unify visuals and remove Tailwind ring dependency
  - Rotate vendors every 2s only when filterVendor === 'all' and vendor count > 3
  - Remove unused imports; keep prop surface minimal; pass setShowFilterModal downward only
- Refactor PricingVendorIntroSkeleton.jsx:
  - Add getCoverStyle() and rect() helpers; rebuild skeleton to match final UI
  - Replace invalid Skeleton.Input usage; add missing keys; unify colors/borders/radius
- Update PricingTopSection.jsx:
  - Manage filter modal locally; drop redundant prop passing
- Update PricingVendorIntroWithSkeleton.jsx:
  - Align prop interface; forward only required props and keep useMinimumLoadingTime
- Add: web/src/components/table/model-pricing/layout/header/SearchActions.jsx
- Lint: all files pass; no dark:* classes present in this scope

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/SearchActions.jsx (new)
2025-08-23 21:11:40 +08:00
t0ng7u
5d7ab194e2 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-23 19:30:07 +08:00
t0ng7u
8a329f6522 🎨 refactor(ui): use lucide-react for search/refresh and chevron icons
- DashboardHeader.jsx: replace Semi's IconSearch/IconRefresh with lucide-react's Search/RefreshCw (size 16), preserve existing button styles
- UptimePanel.jsx: replace Semi's IconRefresh with lucide-react's RefreshCw (size 14), keep styling intact
- UserArea.jsx: replace Semi's IconChevronDown with lucide-react's ChevronDown (size 14), preserve visual parity
- Update imports: remove @douyinfe/semi-icons usage where replaced; add lucide-react imports
- Verified no remaining IconSearch/IconRefresh in dashboard; no new linter errors

Motivation: unify icon library for core actions and improve UI consistency.
Follow-ups: consider migrating remaining Semi icons (e.g., plus/minus, charts) to lucide-react.
2025-08-23 19:29:56 +08:00
Calcium-Ion
4200edb983 Merge pull request #1161 from lollipopkit/main
feat: query usage of token
2025-08-23 15:58:25 +08:00
CaIon
93ce48aca8 feat: restructure token usage routes and enhance token retrieval logic 2025-08-23 15:45:43 +08:00
Calcium-Ion
df1ec4832c Merge branch 'alpha' into main 2025-08-23 15:27:08 +08:00
Calcium-Ion
e3a38d27f5 Merge pull request #1611 from nekohy/feats-zhipu_4v-anthropic
Feats:Standardize ClaudeHandler, add Zhipu_4v Anthropic native channel support
2025-08-23 13:48:18 +08:00
Calcium-Ion
754498a012 Merge pull request #1635 from feitianbubu/pr/fix-task-info-channel-type
Pr/fix task info channel type
2025-08-23 13:46:52 +08:00
Calcium-Ion
4226746675 Merge pull request #1642 from QuantumNous/fix-retry-and-rerank
fix: retry requeset body incorrect and fix rerank
2025-08-23 13:46:40 +08:00
CaIon
94536be9be fix: enhance error handling for invalid request types in relay handlers 2025-08-23 13:34:56 +08:00
CaIon
2c6a9245ee refactor: rename relay-text.go to compatible_handler.go for clarity 2025-08-23 13:13:57 +08:00
CaIon
fc18a3c89e fix: improve request handling by deep copying OpenAIResponsesRequest 2025-08-23 13:13:10 +08:00
CaIon
4f23e53002 feat: 修复重试后请求结构混乱,修复rerank端点无法使用 2025-08-23 13:12:15 +08:00
t0ng7u
005e9659e1 🐛 fix(header): prevent NotificationButton from shrinking when unread badge appears
- Remove `size='small'` when the button is wrapped by `Badge`
- Keep button dimensions consistent with/without badge
- Preserve 18px icon size and existing styles/accessibility
- Lint check passed with no issues

Files: web/src/components/layout/HeaderBar/NotificationButton.jsx
2025-08-23 03:40:32 +08:00
t0ng7u
43c6bbb3ad 📱 fix(ui/topup): make stats numbers responsive on mobile
Reduce KPI font size on small screens to prevent overlapping of large
numbers while preserving the desktop layout.

Changes:
- InvitationCard.jsx: use `text-base sm:text-2xl` for
  pending earnings, total earnings, and invite count.
- RechargeCard.jsx: use `text-base sm:text-2xl` for
  current balance, historical usage, and request count.

Impact:
- Visual-only; no behavioral changes.
- Desktop/tablet unchanged.
- Lint passes.

Files:
- web/src/components/topup/InvitationCard.jsx
- web/src/components/topup/RechargeCard.jsx
2025-08-23 03:36:18 +08:00
t0ng7u
def4d16c73 🎨 refactor(ui): harden iframe messaging
- useHeaderBar.js
  - Wrap handlers with useCallback (logout, language/theme toggle, mobile menu)
  - Add null checks and try/catch around iframe postMessage (theme & language)
  - Keep minimal effect deps; remove unused StatusContext dispatch
  - Logo preload effect safe and scoped to `logo`

- index.jsx
  - No functional changes; locale memoization remains stable

Chore:
- Lint clean; no runtime warnings
- Verified no render loops or performance regressions
2025-08-23 03:17:03 +08:00
t0ng7u
61ae19ac82 🌓 feat(ui): add auto theme mode, refactor ThemeToggle, optimize header theme handling
- Feature: Introduce 'auto' theme mode
  - Detect system preference via matchMedia('(prefers-color-scheme: dark)')
  - Add useActualTheme context to expose the effective theme ('light'|'dark')
  - Persist selected mode in localStorage ('theme-mode') with 'auto' as default
  - Apply/remove `dark` class on <html> and sync `theme-mode` on <body>
  - Broadcast effective theme to iframes

- UI: Redesign ThemeToggle with Dropdown items and custom highlight
  - Replace non-existent IconMonitor with IconRefresh
  - Use Dropdown.Menu + Dropdown.Item with built-in icon prop
  - Selected state uses custom background highlight; hover state preserved
  - Remove checkmark; selection relies on background styling
  - Current button icon reflects selected mode

- Performance: reduce re-renders and unnecessary effects
  - Memoize theme options and current button icon (useMemo)
  - Simplify handleThemeToggle to accept only explicit modes ('light'|'dark'|'auto')
  - Minimize useEffect dependencies; remove unrelated deps

- Header: streamline useHeaderBar
  - Use useActualTheme for iframe theme messaging
  - Remove unused statusDispatch
  - Remove isNewYear from theme effect dependencies

- Home: send effective theme (useActualTheme) to external content iframes

- i18n: add/enhance theme-related copy in locales (en/zh)

- Chore: minor code cleanup and consistency
  - Improve readability and maintainability
  - Lint clean; no functional regressions
2025-08-23 03:02:35 +08:00
t0ng7u
08add538a0 💄 style(ui): enhance table scroll card scrollbar visibility and appearance
- Make Y-axis scrollbar visible for .table-scroll-card .semi-card-body
- Reduce scrollbar width from 6px to 4px for a more subtle appearance
- Decrease scrollbar opacity from 0.3 to 0.2 for lighter color
- Adjust hover opacity from 0.5 to 0.35 for consistent lighter theme
- Remove previous scrollbar hiding styles to improve user experience

This change improves the usability of table scroll cards by providing
visual feedback for scrollable content while maintaining a clean,
unobtrusive design aesthetic.
2025-08-23 02:14:17 +08:00
t0ng7u
bd166b2f77 🚀 perf(vite): remove visactor manual chunk to avoid preloading on Home
Home was unexpectedly loading the `visactor-*.js` bundle on first paint. This
happened because the Vite manualChunks entry created a standalone vendor
chunk for VisActor, which Vite then preloaded on the initial route.

What’s changed
- Removed `visactor` from `build.rollupOptions.output.manualChunks` in `web/vite.config.js`.

Why
- Prevents VisActor from being preloaded on the Home page.
- Restores the intended behavior: VisActor loads only when the Dashboard (data
  analytics) is visited.

Impact
- Smaller initial payload and fewer network requests on Home.
- No functional changes to charts; loading behavior is now route-driven.

Test plan
- Build the app: `cd web && npm run build`.
- Open the preview and visit `/`: ensure no `visactor-*.js` is requested.
- Navigate to `/console` (Dashboard): ensure `visactor-*.js` loads as expected.
2025-08-23 01:54:32 +08:00
t0ng7u
8b7384e47f ♻️ refactor(home): build serverAddress using ${window.location.origin}
Replace the fallback assignment of serverAddress in `web/src/pages/Home/index.jsx`
to use a template literal for `window.location.origin`.

- Aligns with the preferred style for constructing base URLs
- Keeps formatting consistent across the app
- No functional changes; behavior remains the same
- Lint passes with no new warnings or errors

Files affected:
- web/src/pages/Home/index.jsx
2025-08-23 01:42:32 +08:00
t0ng7u
60dc032cb8 refactor: unify layout, adopt Semi UI Forms, dynamic presets, and fix duplicate requests
- Unify TopUp into a single-page layout and remove tabs
- Replace custom inputs with Semi UI Form components (Form.Input, Form.InputNumber, Form.Slot)
- Move online recharge form into the stats Card content for tighter, consistent layout
- Add account stats Card with blue theme (consistent with InvitationCard style)
- Remove RightStatsCard and inline the stats UI directly in RechargeCard
- Change preset amount UI to horizontal quick-action Buttons; swap order with payment methods
- Replace payment method Cards with Semi UI Buttons
  - Use Button icon prop for Alipay/WeChat/Stripe with brand colors
  - Use built-in Button loading; remove custom “processing...” text
- Replace custom spinners with Semi UI Spin and keep Skeleton for amount loading
- Wrap Redeem Code in a Card; use Typography for “Looking for a code? Buy Redeem Code” link
- Show info Banner when online recharge is disabled (instead of warning)

TopUp data flow and logic
- Generate preset amounts from min_topup using multipliers [1,5,10,30,50,100,300,500]
- Deduplicate /api/user/aff using a ref guard; fetch only once on mount
- Simplify user self fetch: update context only; remove unused local states and helpers
- Normalize payment method keys to alipay/wxpay/stripe and assign default colors

Cleanup
- Delete web/src/components/topup/RightStatsCard.jsx
- Remove unused helpers and local states in index.jsx (userQuota, userDataLoading, getUsername)

Dev notes
- No API changes; UI/UX refactor only
- Lint clean (no new linter errors)

Files
- web/src/components/topup/RechargeCard.jsx
- web/src/components/topup/index.jsx
- web/src/components/topup/InvitationCard.jsx (visual parity reference)
- web/src/components/topup/RightStatsCard.jsx (removed)
2025-08-23 01:32:54 +08:00
t0ng7u
d47190f1fd 🧹 refactor(settings): remove models API usage from Personal Settings
Personal Settings no longer needs to fetch `/api/user/models` since models are now displayed directly. This change removes the unused data flow to simplify the component and avoid unnecessary requests.

Changes:
- Removed `models` and `modelsLoading` state from `web/src/components/settings/PersonalSetting.jsx`
- Removed `loadModels` function and its invocation in the initial effect
- Kept UI behavior unchanged; no functional differences on the Personal Settings page

Notes:
- Lint passes with no new issues
- Other parts of the app still using `/api/user/models` (e.g., Tokens pages) are intentionally left intact

Rationale:
- Models are already displayed; the API call in Personal Settings became redundant
2025-08-22 23:01:32 +08:00
feitianbubu
b5040e0182 fix: channel type nil point 2025-08-22 15:55:25 +08:00
Calcium-Ion
c826d06d2c Merge pull request #1627 from QuantumNous/feature/new-partner-intro
🤝 docs(README): Introduction to New Partners
2025-08-21 12:58:53 +08:00
t0ng7u
7c058bfee3 🤝 docs(README): Enhancing Partner Layout 2025-08-21 12:51:49 +08:00
t0ng7u
b5e55c81d4 🤝 docs(README): Introduction to New Partners 2025-08-21 12:42:19 +08:00
Nekohy
652d71d799 feats:Standardize ClaudeHandler, add zhipu_4v Anthropic native support 2025-08-18 13:14:48 +08:00
wzxjohn
3b26810c17 fix(adaptor): missing first text delta while convert OpenAI to Claude 2025-08-13 09:57:06 +08:00
feitianbubu
3768fc37da feat: init default vendor 2025-08-11 16:47:12 +08:00
creamlike1024
6cf84b118b feat: update openai websearch price 2025-08-10 13:37:49 +08:00
feitianbubu
f3b7ac508d feat: add kling video text2Video when image is empty 2025-07-19 10:16:31 +08:00
Xyfacai
cd7594f623 feat: dalle 格式支持自定义参数 2025-06-09 22:14:51 +08:00
lollipopkit🏳️‍⚧️
6c4242ad2a Merge branch 'QuantumNous:main' into main 2025-06-05 18:26:43 +08:00
lollipopkit🏳️‍⚧️
530af5e358 feat: /api/token/usage 2025-04-29 17:13:28 +08:00
439 changed files with 27788 additions and 10621 deletions

View File

@@ -56,8 +56,6 @@
# SESSION_SECRET=random_string
# 其他配置
# 渠道测试频率(单位:秒)
# CHANNEL_TEST_FREQUENCY=10
# 生成默认token
# GENERATE_DEFAULT_TOKEN=false
# Cohere 安全设置

View File

@@ -1,21 +0,0 @@
name: Check PR Branching Strategy
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-branching-strategy:
runs-on: ubuntu-latest
steps:
- name: Enforce branching strategy
run: |
if [[ "${{ github.base_ref }}" == "main" ]]; then
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
exit 1
fi
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
exit 1
fi
echo "Branching strategy check passed."

View File

@@ -96,7 +96,11 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 思考转内容功能
17. 🔄 针对用户的模型限流功能
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费
18. 🔄 请求格式转换功能,支持以下三种格式转换
1. OpenAI Chat Completions => Claude Messages
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
3. OpenAI Chat Completions => Gemini Chat
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1.`系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道:

19
common/copy.go Normal file
View File

@@ -0,0 +1,19 @@
package common
import (
"fmt"
"github.com/jinzhu/copier"
)
func DeepCopy[T any](src *T) (*T, error) {
if src == nil {
return nil, fmt.Errorf("copy source cannot be nil")
}
var dst T
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
if err != nil {
return nil, err
}
return &dst, nil
}

View File

@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=30000"
var SQLitePath = "one-api.db?_busy_timeout=30000"

View File

@@ -2,12 +2,13 @@ package common
import (
"bytes"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/constant"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const KeyRequestBody = "key_request_body"

View File

@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func GetJsonType(data json.RawMessage) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return "unknown"
}
firstChar := bytes.TrimSpace(data)[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}

View File

@@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string {
return fmt.Sprintf("%d", inter.(int))
case float64:
return fmt.Sprintf("%f", inter.(float64))
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
}
return "Not Implemented"
return fmt.Sprintf("%v", inter)
}
func UnescapeHTML(x string) interface{} {
@@ -257,32 +265,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}

View File

@@ -27,6 +27,7 @@ const (
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelHeaderOverride ContextKey = "header_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
ContextKeyChannelModelMapping ContextKey = "model_mapping"

View File

@@ -10,7 +10,7 @@ import (
"one-api/constant"
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/types"
"strconv"
"time"
@@ -342,7 +342,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
}
availableBalanceCny := response.Data.AvailableBalance
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}

View File

@@ -20,6 +20,7 @@ import (
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/setting/operation_setting"
"one-api/types"
"strconv"
"strings"
@@ -234,7 +235,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true)
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
return testResult{
context: c,
localErr: err,
@@ -477,15 +478,26 @@ func TestAllChannels(c *gin.Context) {
return
}
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")
_ = testAllChannels(false)
common.SysLog("channel test finished")
}
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(10 * time.Minute)
continue
}
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
})
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"strconv"
"strings"
@@ -380,6 +381,85 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 验证2FA后获取渠道密钥
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 获取2FA记录并验证
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, fmt.Errorf("用户未启用2FA无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// validateTwoFactorAuth 统一的2FA验证函数
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// 尝试验证TOTP
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
return true
}
}
// 尝试验证备用码
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
return true
}
return false
}
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
@@ -481,7 +561,7 @@ func AddChannel(c *gin.Context) {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -506,7 +586,7 @@ func AddChannel(c *gin.Context) {
}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
@@ -761,7 +841,7 @@ func UpdateChannel(c *gin.Context) {
}
// 处理 Vertex AI 的特殊情况
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)

View File

@@ -13,6 +13,7 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/setting/system_setting"
"time"
"github.com/gin-gonic/gin"
@@ -259,7 +260,7 @@ func GetAllMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
@@ -284,7 +285,7 @@ func GetUserMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}

View File

@@ -39,6 +39,8 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
data := gin.H{
"version": common.Version,
@@ -56,11 +58,7 @@ func GetStatus(c *gin.Context) {
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"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,
"server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
@@ -73,15 +71,15 @@ func GetStatus(c *gin.Context) {
"enable_data_export": common.DataExportEnabled,
"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,
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
@@ -89,6 +87,10 @@ func GetStatus(c *gin.Context) {
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
// 模块管理配置
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
@@ -247,7 +249,7 @@ func SendPasswordResetEmail(c *gin.Context) {
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+

View File

@@ -207,6 +207,7 @@ func ListModels(c *gin.Context, modelType int) {
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
"object": "list",
})
}
}

604
controller/model_sync.go Normal file
View File

@@ -0,0 +1,604 @@
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 上游地址
const (
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
)
func normalizeLocale(locale string) (string, bool) {
l := strings.ToLower(strings.TrimSpace(locale))
switch l {
case "en", "zh", "ja":
return l, true
default:
return "", false
}
}
func getUpstreamBase() string {
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
}
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
base := strings.TrimRight(getUpstreamBase(), "/")
if l, ok := normalizeLocale(locale); ok && l != "" {
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
}
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
}
type upstreamEnvelope[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []T `json:"data"`
}
type upstreamModel struct {
Description string `json:"description"`
Endpoints json.RawMessage `json:"endpoints"`
Icon string `json:"icon"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
Tags string `json:"tags"`
VendorName string `json:"vendor_name"`
}
type upstreamVendor struct {
Description string `json:"description"`
Icon string `json:"icon"`
Name string `json:"name"`
Status int `json:"status"`
}
var (
etagCache = make(map[string]string)
bodyCache = make(map[string][]byte)
cacheMutex sync.RWMutex
)
type overwriteField struct {
ModelName string `json:"model_name"`
Fields []string `json:"fields"`
}
type syncRequest struct {
Overwrite []overwriteField `json:"overwrite"`
Locale string `json:"locale"`
}
func newHTTPClient() *http.Client {
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
return &http.Client{Transport: transport}
}
var httpClient = newHTTPClient()
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
var lastErr error
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
if attempts < 1 {
attempts = 1
}
baseDelay := 200 * time.Millisecond
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
maxBytes := int64(maxMB) << 20
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// ETag conditional request
cacheMutex.RLock()
if et := etagCache[url]; et != "" {
req.Header.Set("If-None-Match", et)
}
cacheMutex.RUnlock()
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
// backoff with jitter
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := json.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := json.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
if vendorName == "" {
return 0
}
if id, ok := vendorIDCache[vendorName]; ok {
return id
}
var existing model.Vendor
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
vendorIDCache[vendorName] = existing.Id
return existing.Id
}
uv := vendorByName[vendorName]
v := &model.Vendor{
Name: vendorName,
Description: uv.Description,
Icon: coalesce(uv.Icon, ""),
Status: chooseStatus(uv.Status, 1),
}
if err := v.Insert(); err == nil {
*createdVendors++
vendorIDCache[vendorName] = v.Id
return v.Id
}
vendorIDCache[vendorName] = 0
return 0
}
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
func SyncUpstreamModels(c *gin.Context) {
var req syncRequest
// 允许空体
_ = c.ShouldBindJSON(&req)
// 1) 获取未配置模型列表
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"created_models": 0,
"created_vendors": 0,
"skipped_models": []string{},
}})
return
}
// 2) 拉取上游 vendors 与 models
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// vendor 失败不拦截
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
// 建立映射
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
}
}
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
createdModels := 0
createdVendors := 0
updatedModels := 0
var skipped []string
var createdList []string
var updatedList []string
// 本地缓存vendorName -> id
vendorIDCache := make(map[string]int)
for _, name := range missing {
up, ok := modelByName[name]
if !ok {
skipped = append(skipped, name)
continue
}
// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
var existing model.Model
if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
if existing.SyncOfficial == 0 {
skipped = append(skipped, name)
continue
}
}
// 确保 vendor 存在
vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 创建模型
mi := &model.Model{
ModelName: name,
Description: up.Description,
Icon: up.Icon,
Tags: up.Tags,
VendorID: vendorID,
Status: chooseStatus(up.Status, 1),
NameRule: up.NameRule,
}
if err := mi.Insert(); err == nil {
createdModels++
createdList = append(createdList, name)
} else {
skipped = append(skipped, name)
}
}
// 4) 处理可选覆盖(更新本地已有模型的差异字段)
if len(req.Overwrite) > 0 {
// vendorIDCache 已用于创建阶段,可复用
for _, ow := range req.Overwrite {
up, ok := modelByName[ow.ModelName]
if !ok {
continue
}
var local model.Model
if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
continue
}
// 跳过被禁用官方同步的模型
if local.SyncOfficial == 0 {
continue
}
// 映射 vendor
newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 应用字段覆盖(事务)
_ = model.DB.Transaction(func(tx *gorm.DB) error {
needUpdate := false
if containsField(ow.Fields, "description") {
local.Description = up.Description
needUpdate = true
}
if containsField(ow.Fields, "icon") {
local.Icon = up.Icon
needUpdate = true
}
if containsField(ow.Fields, "tags") {
local.Tags = up.Tags
needUpdate = true
}
if containsField(ow.Fields, "vendor") {
local.VendorID = newVendorID
needUpdate = true
}
if containsField(ow.Fields, "name_rule") {
local.NameRule = up.NameRule
needUpdate = true
}
if containsField(ow.Fields, "status") {
local.Status = chooseStatus(up.Status, local.Status)
needUpdate = true
}
if !needUpdate {
return nil
}
if err := tx.Save(&local).Error; err != nil {
return err
}
updatedModels++
updatedList = append(updatedList, ow.ModelName)
return nil
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"created_models": createdModels,
"created_vendors": createdVendors,
"updated_models": updatedModels,
"skipped_models": skipped,
"created_list": createdList,
"updated_list": updatedList,
"source": gin.H{
"locale": req.Locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}
func containsField(fields []string, key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, f := range fields {
if strings.ToLower(strings.TrimSpace(f)) == key {
return true
}
}
return false
}
func coalesce(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func chooseStatus(primary, fallback int) int {
if primary == 0 && fallback != 0 {
return fallback
}
if primary != 0 {
return primary
}
return 1
}
// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
func SyncUpstreamPreview(c *gin.Context) {
// 1) 拉取上游数据
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
locale := c.Query("locale")
modelsURL, vendorsURL := getUpstreamURLs(locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
upstreamNames := make([]string, 0, len(modelsEnv.Data))
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
upstreamNames = append(upstreamNames, m.ModelName)
}
}
// 2) 本地已有模型
var locals []model.Model
if len(upstreamNames) > 0 {
_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
}
// 本地 vendor 名称映射
vendorIdSet := make(map[int]struct{})
for _, m := range locals {
if m.VendorID != 0 {
vendorIdSet[m.VendorID] = struct{}{}
}
}
vendorIDs := make([]int, 0, len(vendorIdSet))
for id := range vendorIdSet {
vendorIDs = append(vendorIDs, id)
}
idToVendorName := make(map[int]string)
if len(vendorIDs) > 0 {
var dbVendors []model.Vendor
_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
for _, v := range dbVendors {
idToVendorName[v.Id] = v.Name
}
}
// 3) 缺失且上游存在的模型
missingList, _ := model.GetMissingModels()
var missing []string
for _, name := range missingList {
if _, ok := modelByName[name]; ok {
missing = append(missing, name)
}
}
// 4) 计算冲突字段
type conflictField struct {
Field string `json:"field"`
Local interface{} `json:"local"`
Upstream interface{} `json:"upstream"`
}
type conflictItem struct {
ModelName string `json:"model_name"`
Fields []conflictField `json:"fields"`
}
var conflicts []conflictItem
for _, local := range locals {
up, ok := modelByName[local.ModelName]
if !ok {
continue
}
fields := make([]conflictField, 0, 6)
if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
}
if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
}
if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
}
// vendor 对比使用名称
localVendor := idToVendorName[local.VendorID]
if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
}
if local.NameRule != up.NameRule {
fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
}
if local.Status != chooseStatus(up.Status, local.Status) {
fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
}
if len(fields) > 0 {
conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"missing": missing,
"conflicts": conflicts,
"source": gin.H{
"locale": locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}

375
controller/oauth.go Normal file
View File

@@ -0,0 +1,375 @@
package controller
import (
"encoding/json"
"net/http"
"one-api/model"
"one-api/setting/system_setting"
"one-api/src/oauth"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v5"
"one-api/middleware"
"strconv"
"strings"
)
// GetJWKS 获取JWKS公钥集
func GetJWKS(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// lazy init if needed
_ = oauth.EnsureInitialized()
jwks := oauth.GetJWKS()
if jwks == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "JWKS not available",
})
return
}
// 设置CORS headers
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Cache-Control", "public, max-age=3600") // 缓存1小时
// 返回JWKS
c.Header("Content-Type", "application/json")
// 将JWKS转换为JSON字符串
jsonData, err := json.Marshal(jwks)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to marshal JWKS",
})
return
}
c.String(http.StatusOK, string(jsonData))
}
// OAuthTokenEndpoint OAuth2 令牌端点
func OAuthTokenEndpoint(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "unsupported_grant_type",
"error_description": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
// 只允许application/x-www-form-urlencoded内容类型
contentType := c.GetHeader("Content-Type")
if contentType == "" || !strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
})
return
}
// lazy init
if err := oauth.EnsureInitialized(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
return
}
oauth.HandleTokenRequest(c)
}
// OAuthAuthorizeEndpoint OAuth2 授权端点
func OAuthAuthorizeEndpoint(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "server_error",
"error_description": "OAuth2 server is disabled",
})
return
}
if err := oauth.EnsureInitialized(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
return
}
oauth.HandleAuthorizeRequest(c)
}
// OAuthServerInfo 获取OAuth2服务器信息
func OAuthServerInfo(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 返回OAuth2服务器的基本信息类似OpenID Connect Discovery
issuer := settings.Issuer
if issuer == "" {
scheme := "https"
if c.Request.TLS == nil {
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
scheme = hdr
} else {
scheme = "http"
}
}
issuer = scheme + "://" + c.Request.Host
}
base := issuer + "/api"
c.JSON(http.StatusOK, gin.H{
"issuer": issuer,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"jwks_uri": base + "/.well-known/jwks.json",
"grant_types_supported": settings.AllowedGrantTypes,
"response_types_supported": []string{"code", "token"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
"default_private_key_path": settings.DefaultPrivateKeyPath,
})
}
// OAuthOIDCConfiguration OIDC discovery document
func OAuthOIDCConfiguration(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
return
}
issuer := settings.Issuer
if issuer == "" {
scheme := "https"
if c.Request.TLS == nil {
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
scheme = hdr
} else {
scheme = "http"
}
}
issuer = scheme + "://" + c.Request.Host
}
base := issuer + "/api"
c.JSON(http.StatusOK, gin.H{
"issuer": issuer,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"userinfo_endpoint": base + "/oauth/userinfo",
"jwks_uri": base + "/.well-known/jwks.json",
"response_types_supported": []string{"code", "token"},
"grant_types_supported": settings.AllowedGrantTypes,
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
"default_private_key_path": settings.DefaultPrivateKeyPath,
})
}
// OAuthIntrospect 令牌内省端点RFC 7662
func OAuthIntrospect(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
token := c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"active": false,
})
return
}
tokenString := token
// 验证并解析JWT
parsed, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
pub := oauth.GetPublicKeyByKid(func() string {
if v, ok := token.Header["kid"].(string); ok {
return v
}
return ""
}())
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err != nil || !parsed.Valid {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
// 检查撤销
if jti, ok := claims["jti"].(string); ok && jti != "" {
if revoked, _ := model.IsTokenRevoked(jti); revoked {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
}
// 有效
resp := gin.H{"active": true}
for k, v := range claims {
resp[k] = v
}
resp["token_type"] = "Bearer"
c.JSON(http.StatusOK, resp)
}
// OAuthRevoke 令牌撤销端点RFC 7009
func OAuthRevoke(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
token := c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
token = c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
// 尝试解析JWT若成功则记录jti到撤销表
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
pub := oauth.GetRSAPublicKey()
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err == nil && parsed != nil && parsed.Valid {
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
var jti string
var exp int64
if v, ok := claims["jti"].(string); ok {
jti = v
}
if v, ok := claims["exp"].(float64); ok {
exp = int64(v)
} else if v, ok := claims["exp"].(int64); ok {
exp = v
}
if jti != "" {
// 如果没有exp默认撤销至当前+TTL 10分钟
if exp == 0 {
exp = time.Now().Add(10 * time.Minute).Unix()
}
_ = model.RevokeToken(jti, exp)
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// OAuthUserInfo returns OIDC userinfo based on access token
func OAuthUserInfo(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
return
}
// 需要 OAuthJWTAuth 中间件注入 claims
claims, ok := middleware.GetOAuthClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
// scope 校验:必须包含 openid
scope, _ := claims["scope"].(string)
if !strings.Contains(" "+scope+" ", " openid ") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient_scope"})
return
}
sub, _ := claims["sub"].(string)
resp := gin.H{"sub": sub}
// 若包含 profile/email scope补充返回
if strings.Contains(" "+scope+" ", " profile ") || strings.Contains(" "+scope+" ", " email ") {
if uid, err := strconv.Atoi(sub); err == nil {
if user, err2 := model.GetUserById(uid, false); err2 == nil && user != nil {
if strings.Contains(" "+scope+" ", " profile ") {
resp["name"] = user.DisplayName
resp["preferred_username"] = user.Username
}
if strings.Contains(" "+scope+" ", " email ") {
resp["email"] = user.Email
resp["email_verified"] = true
}
}
}
}
c.JSON(http.StatusOK, resp)
}

374
controller/oauth_client.go Normal file
View File

@@ -0,0 +1,374 @@
package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
)
// CreateOAuthClientRequest 创建OAuth客户端请求
type CreateOAuthClientRequest struct {
Name string `json:"name" binding:"required"`
ClientType string `json:"client_type" binding:"required,oneof=confidential public"`
GrantTypes []string `json:"grant_types" binding:"required"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes" binding:"required"`
Description string `json:"description"`
RequirePKCE bool `json:"require_pkce"`
}
// UpdateOAuthClientRequest 更新OAuth客户端请求
type UpdateOAuthClientRequest struct {
ID string `json:"id" binding:"required"`
Name string `json:"name" binding:"required"`
ClientType string `json:"client_type" binding:"required,oneof=confidential public"`
GrantTypes []string `json:"grant_types" binding:"required"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes" binding:"required"`
Description string `json:"description"`
RequirePKCE bool `json:"require_pkce"`
Status int `json:"status" binding:"required,oneof=1 2"`
}
// GetAllOAuthClients 获取所有OAuth客户端
func GetAllOAuthClients(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(c.Query("per_page"))
if perPage < 1 || perPage > 100 {
perPage = 20
}
startIdx := (page - 1) * perPage
clients, err := model.GetAllOAuthClients(startIdx, perPage)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// 清理敏感信息
for _, client := range clients {
client.Secret = maskSecret(client.Secret)
}
total, _ := model.CountOAuthClients()
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": clients,
"total": total,
"page": page,
"per_page": perPage,
})
}
// SearchOAuthClients 搜索OAuth客户端
func SearchOAuthClients(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "关键词不能为空",
})
return
}
clients, err := model.SearchOAuthClients(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// 清理敏感信息
for _, client := range clients {
client.Secret = maskSecret(client.Secret)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": clients,
})
}
// GetOAuthClient 获取单个OAuth客户端
func GetOAuthClient(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
client, err := model.GetOAuthClientByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 清理敏感信息
client.Secret = maskSecret(client.Secret)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": client,
})
}
// CreateOAuthClient 创建OAuth客户端
func CreateOAuthClient(c *gin.Context) {
var req CreateOAuthClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
})
return
}
// 验证授权类型
validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"}
for _, grantType := range req.GrantTypes {
if !contains(validGrantTypes, grantType) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的授权类型: " + grantType,
})
return
}
}
// 如果包含authorization_code则必须提供redirect_uris
if contains(req.GrantTypes, "authorization_code") && len(req.RedirectURIs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "授权码模式需要提供重定向URI",
})
return
}
// 生成客户端ID和密钥
clientID := generateClientID()
clientSecret := ""
if req.ClientType == "confidential" {
clientSecret = generateClientSecret()
}
// 获取创建者ID
createdBy := c.GetInt("id")
// 创建客户端
client := &model.OAuthClient{
ID: clientID,
Secret: clientSecret,
Name: req.Name,
ClientType: req.ClientType,
RequirePKCE: req.RequirePKCE,
Status: common.UserStatusEnabled,
CreatedBy: createdBy,
Description: req.Description,
}
client.SetGrantTypes(req.GrantTypes)
client.SetRedirectURIs(req.RedirectURIs)
client.SetScopes(req.Scopes)
err := model.CreateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "创建客户端失败: " + err.Error(),
})
return
}
// 返回结果(包含完整的客户端密钥,仅此一次)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "客户端创建成功",
"client_id": client.ID,
"client_secret": client.Secret, // 仅在创建时返回完整密钥
"data": client,
})
}
// UpdateOAuthClient 更新OAuth客户端
func UpdateOAuthClient(c *gin.Context) {
var req UpdateOAuthClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
})
return
}
// 获取现有客户端
client, err := model.GetOAuthClientByID(req.ID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 验证授权类型
validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"}
for _, grantType := range req.GrantTypes {
if !contains(validGrantTypes, grantType) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的授权类型: " + grantType,
})
return
}
}
// 更新客户端信息
client.Name = req.Name
client.ClientType = req.ClientType
client.RequirePKCE = req.RequirePKCE
client.Status = req.Status
client.Description = req.Description
client.SetGrantTypes(req.GrantTypes)
client.SetRedirectURIs(req.RedirectURIs)
client.SetScopes(req.Scopes)
err = model.UpdateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "更新客户端失败: " + err.Error(),
})
return
}
// 清理敏感信息
client.Secret = maskSecret(client.Secret)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端更新成功",
"data": client,
})
}
// DeleteOAuthClient 删除OAuth客户端
func DeleteOAuthClient(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
err := model.DeleteOAuthClient(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "删除客户端失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端删除成功",
})
}
// RegenerateOAuthClientSecret 重新生成客户端密钥
func RegenerateOAuthClientSecret(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
client, err := model.GetOAuthClientByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 只有机密客户端才能重新生成密钥
if client.ClientType != "confidential" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "只有机密客户端才能重新生成密钥",
})
return
}
// 生成新密钥
client.Secret = generateClientSecret()
err = model.UpdateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "重新生成密钥失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端密钥重新生成成功",
"client_secret": client.Secret, // 返回新生成的密钥
})
}
// generateClientID 生成客户端ID
func generateClientID() string {
return "client_" + randstr.String(16)
}
// generateClientSecret 生成客户端密钥
func generateClientSecret() string {
return randstr.String(32)
}
// maskSecret 掩码密钥显示
func maskSecret(secret string) string {
if len(secret) <= 6 {
return strings.Repeat("*", len(secret))
}
return secret[:3] + strings.Repeat("*", len(secret)-6) + secret[len(secret)-3:]
}
// contains 检查字符串切片是否包含指定值
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}

89
controller/oauth_keys.go Normal file
View File

@@ -0,0 +1,89 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/logger"
"one-api/src/oauth"
)
type rotateKeyRequest struct {
Kid string `json:"kid"`
}
type genKeyFileRequest struct {
Path string `json:"path"`
Kid string `json:"kid"`
Overwrite bool `json:"overwrite"`
}
type importPemRequest struct {
Pem string `json:"pem"`
Kid string `json:"kid"`
}
// RotateOAuthSigningKey rotates the OAuth2 JWT signing key (Root only)
func RotateOAuthSigningKey(c *gin.Context) {
var req rotateKeyRequest
_ = c.BindJSON(&req)
kid, err := oauth.RotateSigningKey(req.Kid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key rotated: "+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
}
// ListOAuthSigningKeys returns current and historical JWKS signing keys
func ListOAuthSigningKeys(c *gin.Context) {
keys := oauth.ListSigningKeys()
c.JSON(http.StatusOK, gin.H{"success": true, "data": keys})
}
// DeleteOAuthSigningKey deletes a non-current key by kid
func DeleteOAuthSigningKey(c *gin.Context) {
kid := c.Param("kid")
if kid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "kid required"})
return
}
if err := oauth.DeleteSigningKey(kid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key deleted: "+kid)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GenerateOAuthSigningKeyFile generates a private key file and rotates current kid
func GenerateOAuthSigningKeyFile(c *gin.Context) {
var req genKeyFileRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "path required"})
return
}
kid, err := oauth.GenerateAndPersistKey(req.Path, req.Kid, req.Overwrite)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key generated to file: "+req.Path+" kid="+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid, "path": req.Path})
}
// ImportOAuthSigningKey imports PEM text and rotates current kid
func ImportOAuthSigningKey(c *gin.Context) {
var req importPemRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Pem == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "pem required"})
return
}
kid, err := oauth.ImportPEMKey(req.Pem, req.Kid)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key imported from PEM, kid="+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
}

View File

@@ -8,7 +8,6 @@ import (
"net/url"
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/system_setting"
"strconv"
"strings"
@@ -45,7 +44,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
values.Set("code", code)
values.Set("grant_type", "authorization_code")
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
formData := values.Encode()
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
if err != nil {

View File

@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
@@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) {
return
}
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) {
var option model.Option
var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) {
})
return
}
switch option.Value.(type) {
case bool:
option.Value = common.Interface2String(option.Value.(bool))
case float64:
option.Value = common.Interface2String(option.Value.(float64))
case int:
option.Value = common.Interface2String(option.Value.(int))
default:
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
@@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value)
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -113,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value)
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -122,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -131,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -140,7 +156,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -149,7 +165,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -158,7 +174,7 @@ func UpdateOption(c *gin.Context) {
return
}
}
err = model.UpdateOption(option.Key, option.Value)
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return

View File

@@ -1,24 +1,24 @@
package controller
import (
"net/http"
"one-api/setting/ratio_setting"
"net/http"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"one-api/logger"
"strings"
@@ -21,8 +23,26 @@ const (
defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8
maxRatioConfigBytes = 10 << 20 // 10MB
floatEpsilon = 1e-9
)
func nearlyEqual(a, b float64) bool {
if a > b {
return a-b < floatEpsilon
}
return b-a < floatEpsilon
}
func valuesEqual(a, b interface{}) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
if aok && bok {
return nearlyEqual(af, bf)
}
return a == b
}
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
type upstreamResult struct {
@@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) {
sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
// 对 github.io 优先尝试 IPv4失败则回退 IPv6
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
client := &http.Client{Transport: transport}
for _, chn := range upstreams {
wg.Add(1)
@@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) {
defer func() { <-sem }()
endpoint := chItem.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
var fullURL string
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
fullURL = endpoint
} else {
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL = chItem.BaseURL + endpoint
}
fullURL := chItem.BaseURL + endpoint
uniqueName := chItem.Name
if chItem.ID != 0 {
@@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) {
return
}
resp, err := client.Do(httpReq)
if err != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
// 简单重试:最多 3 次,指数退避
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(httpReq)
if lastErr == nil {
break
}
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return
}
defer resp.Body.Close()
@@ -132,6 +182,12 @@ func FetchUpstreamRatios(c *gin.Context) {
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
@@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) {
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
if err := json.NewDecoder(limited).Decode(&body); err != nil {
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
@@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) {
return
}
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
// 尝试按 type1 解析
var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
@@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
upstreamValue = val
hasUpstreamValue = true
if localValue != nil && localValue != val {
if localValue != nil && !valuesEqual(localValue, val) {
hasDifference = true
} else if localValue == val {
} else if valuesEqual(localValue, val) {
upstreamValue = "same"
}
}
@@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) {
}
}
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: -100,
Name: "官方倍率预设",
BaseURL: "https://basellm.github.io",
Status: 1,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@@ -3,7 +3,6 @@ package controller
import (
"bytes"
"fmt"
"github.com/bytedance/gopkg/util/gopool"
"io"
"log"
"net/http"
@@ -22,6 +21,8 @@ import (
"one-api/types"
"strings"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
@@ -138,15 +139,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
preConsumedQuota, newAPIError := service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if newAPIError != nil {
return
}
defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil && preConsumedQuota != 0 {
service.ReturnPreConsumedQuota(c, relayInfo, preConsumedQuota)
if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
service.ReturnPreConsumedQuota(c, relayInfo)
}
}()
@@ -276,14 +277,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
gopool.Go(func() {
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.Error())
}
})
})
}
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
@@ -383,11 +383,14 @@ func RelayNotFound(c *gin.Context) {
func RelayTask(c *gin.Context) {
retryTimes := common.RetryTimes
channelId := c.GetInt("channel_id")
relayMode := c.GetInt("relay_mode")
group := c.GetString("group")
originalModel := c.GetString("original_model")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
taskErr := taskRelayHandler(c, relayMode)
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
return
}
taskErr := taskRelayHandler(c, relayInfo)
if taskErr == nil {
retryTimes = 0
}
@@ -407,7 +410,7 @@ func RelayTask(c *gin.Context) {
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayMode)
taskErr = taskRelayHandler(c, relayInfo)
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
@@ -422,13 +425,13 @@ func RelayTask(c *gin.Context) {
}
}
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
var err *dto.TaskError
switch relayMode {
switch relayInfo.RelayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
err = relay.RelayTaskFetch(c, relayMode)
err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
default:
err = relay.RelayTaskSubmit(c, relayMode)
err = relay.RelayTaskSubmit(c, relayInfo)
}
return err
}

View File

@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true"
}
return "false"
}
}

View File

@@ -94,7 +94,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
} else {
task.Data = responseBody
task.Data = redactVideoResponseBody(responseBody)
}
now := time.Now().Unix()
@@ -113,11 +113,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.StartTime = now
}
case model.TaskStatusSuccess:
task.Progress = "100%"
task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Url
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
task.FailReason = taskResult.Url
}
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
@@ -146,3 +148,37 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return nil
}
func redactVideoResponseBody(body []byte) []byte {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return body
}
resp, _ := m["response"].(map[string]any)
if resp != nil {
delete(resp, "bytesBase64Encoded")
if v, ok := resp["video"].(string); ok {
resp["video"] = truncateBase64(v)
}
if vs, ok := resp["videos"].([]any); ok {
for i := range vs {
if vm, ok := vs[i].(map[string]any); ok {
delete(vm, "bytesBase64Encoded")
}
}
}
}
b, err := json.Marshal(m)
if err != nil {
return body
}
return b
}
func truncateBase64(s string) string {
const maxKeep = 256
if len(s) <= maxKeep {
return s
}
return s[:maxKeep] + "..."
}

View File

@@ -5,6 +5,7 @@ import (
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@@ -82,6 +83,57 @@ func GetTokenStatus(c *gin.Context) {
})
}
func GetTokenUsage(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "No Authorization header",
})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Invalid Bearer token",
})
return
}
tokenKey := parts[1]
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
c.JSON(http.StatusOK, gin.H{
"code": true,
"message": "ok",
"data": gin.H{
"object": "token_usage",
"name": token.Name,
"total_granted": token.RemainQuota + token.UsedQuota,
"total_used": token.UsedQuota,
"total_available": token.RemainQuota,
"unlimited_quota": token.UnlimitedQuota,
"model_limits": token.GetModelLimitsMap(),
"model_limits_enabled": token.ModelLimitsEnabled,
"expires_at": expiredAt,
},
})
}
func AddToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)

View File

@@ -9,6 +9,8 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"strconv"
"sync"
"time"
@@ -19,6 +21,44 @@ import (
"github.com/shopspring/decimal"
)
func GetTopUpInfo(c *gin.Context) {
// 获取支付方式
payMethods := operation_setting.PayMethods
// 如果启用了 Stripe 支付,添加到支付方法列表
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
// 检查是否已经包含 Stripe
hasStripe := false
for _, method := range payMethods {
if method["type"] == "stripe" {
hasStripe = true
break
}
}
if !hasStripe {
stripeMethod := map[string]string{
"name": "Stripe",
"type": "stripe",
"color": "rgba(var(--semi-purple-5), 1)",
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
}
payMethods = append(payMethods, stripeMethod)
}
}
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
}
common.ApiSuccess(c, data)
}
type EpayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
@@ -31,13 +71,13 @@ type AmountRequest struct {
}
func GetEpayClient() *epay.Client {
if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
}
withUrl, err := epay.NewClient(&epay.Config{
PartnerID: setting.EpayId,
Key: setting.EpayKey,
}, setting.PayAddress)
PartnerID: operation_setting.EpayId,
Key: operation_setting.EpayKey,
}, operation_setting.PayAddress)
if err != nil {
return nil
}
@@ -58,15 +98,23 @@ func getPayMoney(amount int64, group string) float64 {
}
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
dPrice := decimal.NewFromFloat(setting.Price)
dPrice := decimal.NewFromFloat(operation_setting.Price)
// apply optional preset discount by the original request amount (if configured), default 1.0
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
if ds > 0 {
discount = ds
}
}
dDiscount := decimal.NewFromFloat(discount)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
return payMoney.InexactFloat64()
}
func getMinTopup() int64 {
minTopup := setting.MinTopUp
minTopup := operation_setting.MinTopUp
if !common.DisplayInCurrencyEnabled {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
@@ -99,13 +147,13 @@ func RequestEpay(c *gin.Context) {
return
}
if !setting.ContainsPayMethod(req.PaymentMethod) {
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)

View File

@@ -8,6 +8,8 @@ import (
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"strconv"
"strings"
"time"
@@ -215,8 +217,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(setting.ServerAddress + "/log"),
CancelURL: stripe.String(setting.ServerAddress + "/topup"),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),
@@ -254,6 +256,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
}
func getStripePayMoney(amount float64, group string) float64 {
originalAmount := amount
if !common.DisplayInCurrencyEnabled {
amount = amount / common.QuotaPerUnit
}
@@ -262,7 +265,14 @@ func getStripePayMoney(amount float64, group string) float64 {
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
// apply optional preset discount by the original request amount (if configured), default 1.0
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount
return payMoney
}

View File

@@ -31,7 +31,7 @@ type Monitor struct {
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"`
Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{
CategoryName: categoryName,
Monitors: []Monitor{},
Monitors: []Monitor{},
}
if url == "" || slug == "" {
return result
}
baseURL := strings.TrimSuffix(url, "/")
var statusData struct {
PublicGroupList []struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"id"`
Name string `json:"name"`
MonitorList []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"monitorList"`
} `json:"publicGroupList"`
}
var heartbeatData struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
@@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
if g.Wait() != nil {
@@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
@@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
return nil
})
}
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
}
}

View File

@@ -210,6 +210,7 @@ func Register(c *gin.Context) {
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
@@ -426,6 +427,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
userRole := c.GetInt("role")
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
@@ -434,14 +436,134 @@ func GetSelf(c *gin.Context) {
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
permissions := calculateUserPermissions(userRole)
// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"email": user.Email,
"group": user.Group,
"quota": user.Quota,
"used_quota": user.UsedQuota,
"request_count": user.RequestCount,
"aff_code": user.AffCode,
"aff_count": user.AffCount,
"aff_quota": user.AffQuota,
"aff_history_quota": user.AffHistoryQuota,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user,
"data": responseData,
})
return
}
// 计算用户权限的辅助函数
func calculateUserPermissions(userRole int) map[string]interface{} {
permissions := map[string]interface{}{}
// 根据用户角色计算权限
if userRole == common.RoleRootUser {
// 超级管理员不需要边栏设置功能
permissions["sidebar_settings"] = false
permissions["sidebar_modules"] = map[string]interface{}{}
} else if userRole == common.RoleAdminUser {
// 管理员可以设置边栏,但不包含系统设置功能
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": map[string]interface{}{
"setting": false, // 管理员不能访问系统设置
},
}
} else {
// 普通用户只能设置个人功能,不包含管理员区域
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": false, // 普通用户不能访问管理员区域
}
}
return permissions
}
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfig(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
func GetUserModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -528,8 +650,8 @@ func UpdateUser(c *gin.Context) {
}
func UpdateSelf(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -537,6 +659,60 @@ func UpdateSelf(c *gin.Context) {
})
return
}
// 检查是否是sidebar_modules更新请求
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
// 获取当前用户设置
currentSetting := user.GetSetting()
// 更新sidebar_modules字段
if sidebarModulesStr, ok := sidebarModules.(string); ok {
currentSetting.SidebarModules = sidebarModulesStr
}
// 保存更新后的设置
user.SetSetting(currentSetting)
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "更新设置失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "设置更新成功",
})
return
}
// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
err = json.Unmarshal(requestDataBytes, &user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
@@ -679,6 +855,7 @@ func CreateUser(c *gin.Context) {
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
@@ -920,6 +1097,7 @@ type UpdateUserSettingRequest struct {
WebhookUrl string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -935,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -983,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是Bark类型验证Bark URL
if req.QuotaWarningType == dto.NotifyTypeBark {
if req.BarkUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Bark推送URL",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL必须以http://或https://开头",
})
return
}
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -1011,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) {
settings.NotificationEmail = req.NotificationEmail
}
// 如果是Bark类型添加Bark URL到设置中
if req.QuotaWarningType == dto.NotifyTypeBark {
settings.BarkUrl = req.BarkUrl
}
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {

View File

@@ -26,6 +26,12 @@ func (r *AudioRequest) IsStream(c *gin.Context) bool {
return false
}
func (r *AudioRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type AudioResponse struct {
Text string `json:"text"`
}

View File

@@ -9,6 +9,14 @@ type ChannelSettings struct {
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
type VertexKeyType string
const (
VertexKeyTypeJSON VertexKeyType = "json"
VertexKeyTypeAPIKey VertexKeyType = "api_key"
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
}

View File

@@ -321,8 +321,14 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
return &tokenCountMeta
}
func (claudeRequest *ClaudeRequest) IsStream(c *gin.Context) bool {
return claudeRequest.Stream
func (c *ClaudeRequest) IsStream(ctx *gin.Context) bool {
return c.Stream
}
func (c *ClaudeRequest) SetModelName(modelName string) {
if modelName != "" {
c.Model = modelName
}
}
func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string {
@@ -482,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
case string:
// 处理简单字符串错误
return &types.ClaudeError{
Type: "error",
Type: "upstream_error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.ClaudeError{
Type: "unknown_error",
Message: fmt.Sprintf("%v", err),
Type: "unknown_upstream_error",
Message: fmt.Sprintf("unknown_error: %v", err),
}
}
}

View File

@@ -48,6 +48,12 @@ func (r *EmbeddingRequest) IsStream(c *gin.Context) bool {
return false
}
func (r *EmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *EmbeddingRequest) ParseInput() []string {
if r.Input == nil {
return make([]string, 0)

View File

@@ -73,6 +73,10 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
return false
}
func (r *GeminiChatRequest) SetModelName(modelName string) {
// GeminiChatRequest does not have a model field, so this method does nothing.
}
func (r *GeminiChatRequest) GetTools() []GeminiChatTool {
var tools []GeminiChatTool
if strings.HasSuffix(string(r.Tools), "[") {
@@ -312,10 +316,61 @@ type GeminiEmbeddingRequest struct {
OutputDimensionality int `json:"outputDimensionality,omitempty"`
}
func (r *GeminiEmbeddingRequest) IsStream(c *gin.Context) bool {
// Gemini embedding requests are not streamed
return false
}
func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, part := range r.Content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type GeminiBatchEmbeddingRequest struct {
Requests []*GeminiEmbeddingRequest `json:"requests"`
}
func (r *GeminiBatchEmbeddingRequest) IsStream(c *gin.Context) bool {
// Gemini batch embedding requests are not streamed
return false
}
func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, request := range r.Requests {
meta := request.GetTokenCountMeta()
if meta != nil && meta.CombineText != "" {
inputTexts = append(inputTexts, meta.CombineText)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
for _, req := range r.Requests {
req.SetModelName(modelName)
}
}
}
type GeminiEmbeddingResponse struct {
Embedding ContentEmbedding `json:"embedding"`
}

View File

@@ -2,7 +2,9 @@ package dto
import (
"encoding/json"
"one-api/common"
"one-api/types"
"reflect"
"strings"
"github.com/gin-gonic/gin"
@@ -12,10 +14,10 @@ type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
N uint `json:"n,omitempty"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style json.RawMessage `json:"style,omitempty"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style json.RawMessage `json:"style,omitempty"`
User json.RawMessage `json:"user,omitempty"`
ExtraFields json.RawMessage `json:"extra_fields,omitempty"`
Background json.RawMessage `json:"background,omitempty"`
@@ -25,6 +27,95 @@ type ImageRequest struct {
PartialImages json.RawMessage `json:"partial_images,omitempty"`
// Stream bool `json:"stream,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
// 用匿名参数接收额外参数
Extra map[string]json.RawMessage `json:"-"`
}
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
// 先解析成 map[string]interface{}
var rawMap map[string]json.RawMessage
if err := common.Unmarshal(data, &rawMap); err != nil {
return err
}
// 用 struct tag 获取所有已定义字段名
knownFields := GetJSONFieldNames(reflect.TypeOf(*i))
// 再正常解析已定义字段
type Alias ImageRequest
var known Alias
if err := common.Unmarshal(data, &known); err != nil {
return err
}
*i = ImageRequest(known)
// 提取多余字段
i.Extra = make(map[string]json.RawMessage)
for k, v := range rawMap {
if _, ok := knownFields[k]; !ok {
i.Extra[k] = v
}
}
return nil
}
// 序列化时需要重新把字段平铺
func (r ImageRequest) MarshalJSON() ([]byte, error) {
// 将已定义字段转为 map
type Alias ImageRequest
alias := Alias(r)
base, err := common.Marshal(alias)
if err != nil {
return nil, err
}
var baseMap map[string]json.RawMessage
if err := common.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
// 合并 ExtraFields
for k, v := range r.Extra {
if _, exists := baseMap[k]; !exists {
baseMap[k] = v
}
}
return json.Marshal(baseMap)
}
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 跳过匿名字段(例如 ExtraFields
if field.Anonymous {
continue
}
tag := field.Tag.Get("json")
if tag == "-" || tag == "" {
continue
}
// 取逗号前字段名(排除 omitempty 等)
name := tag
if commaIdx := indexComma(tag); commaIdx != -1 {
name = tag[:commaIdx]
}
fields[name] = struct{}{}
}
return fields
}
func indexComma(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i
}
}
return -1
}
func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -63,9 +154,16 @@ func (i *ImageRequest) IsStream(c *gin.Context) bool {
return false
}
func (i *ImageRequest) SetModelName(modelName string) {
if modelName != "" {
i.Model = modelName
}
}
type ImageResponse struct {
Data []ImageData `json:"data"`
Created int64 `json:"created"`
Extra any `json:"extra,omitempty"`
}
type ImageData struct {
Url string `json:"url"`

View File

@@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao,zhipu_v4
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
SearchParameters any `json:"search_parameters,omitempty"` //xai
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
// claude
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:"-"`
EnableThinking any `json:"enable_thinking,omitempty"`
// ollama Params
Think json.RawMessage `json:"think,omitempty"`
// baidu v2
WebSearch json.RawMessage `json:"web_search,omitempty"`
// doubao,zhipu_v4
THINKING json.RawMessage `json:"thinking,omitempty"`
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -183,6 +189,12 @@ func (r *GeneralOpenAIRequest) IsStream(c *gin.Context) bool {
return r.Stream
}
func (r *GeneralOpenAIRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
result := make(map[string]any)
data, _ := common.Marshal(r)
@@ -754,27 +766,27 @@ type WebSearchOptions struct {
// https://platform.openai.com/docs/api-reference/responses/create
type OpenAIResponsesRequest struct {
Model string `json:"model"`
Input any `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"`
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 json.RawMessage `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"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -826,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
if len(r.Tools) > 0 {
toolStr, _ := common.Marshal(r.Tools)
texts = append(texts, string(toolStr))
texts = append(texts, string(r.Tools))
}
return &types.TokenCountMeta{
@@ -841,6 +852,20 @@ func (r *OpenAIResponsesRequest) IsStream(c *gin.Context) bool {
return r.Stream
}
func (r *OpenAIResponsesRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any {
var toolsMap []map[string]any
if len(r.Tools) > 0 {
_ = common.Unmarshal(r.Tools, &toolsMap)
}
return toolsMap
}
type Reasoning struct {
Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"`
@@ -867,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
var inputs []MediaInput
// Try string first
if str, ok := r.Input.(string); ok {
// if str, ok := common.GetJsonType(r.Input); ok {
// inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
// return inputs
// }
if common.GetJsonType(r.Input) == "string" {
var str string
_ = common.Unmarshal(r.Input, &str)
inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
return inputs
}
// Try array of parts
if array, ok := r.Input.([]any); ok {
if common.GetJsonType(r.Input) == "array" {
var array []any
_ = common.Unmarshal(r.Input, &array)
for _, itemAny := range array {
// Already parsed MediaInput
if media, ok := itemAny.(MediaInput); ok {

View File

@@ -1,23 +1,23 @@
package dto
type UpstreamDTO struct {
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"`
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"`
}
type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
}
// TestResult 上游测试连通性结果
type TestResult struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// DifferenceItem 差异项
@@ -25,14 +25,14 @@ type TestResult struct {
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct {
Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
}
type SyncableChannel struct {
ID int `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Status int `json:"status"`
}
ID int `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Status int `json:"status"`
}

View File

@@ -8,6 +8,7 @@ import (
type Request interface {
GetTokenCountMeta() *types.TokenCountMeta
IsStream(c *gin.Context) bool
SetModelName(modelName string)
}
type BaseRequest struct {
@@ -18,7 +19,7 @@ func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta {
TokenType: types.TokenTypeTokenizer,
}
}
func (b *BaseRequest) IsStream(c *gin.Context) bool {
return false
}
func (b *BaseRequest) SetModelName(modelName string) {}

View File

@@ -37,6 +37,12 @@ func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
}
func (r *RerankRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *RerankRequest) GetReturnDocuments() bool {
if r.ReturnDocuments == nil {
return false

View File

@@ -6,11 +6,14 @@ type UserSetting struct {
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
}
var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
)

View File

@@ -0,0 +1,326 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
<style>
:root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
h1 { font-size: 22px; margin:0 0 16px; }
.card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
.row { display:flex; gap:12px; flex-wrap:wrap; }
.col { flex: 1 1 280px; display:flex; flex-direction:column; }
label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
textarea { min-height: 100px; resize: vertical; }
.btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
button.primary { background: var(--accent); border-color: var(--accent); color:white; }
button.ok { background: var(--ok); border-color: var(--ok); color:white; }
button.warn { background: var(--warn); border-color: var(--warn); color:black; }
button.ghost { background: transparent; }
.muted { color: var(--muted); font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
.pill { padding: 3px 8px; border-radius:999px; font-size: 12px; border:1px solid var(--border); background:#0f1115; }
.ok { color: #10b981; }
.err { color: #ef4444; }
.sep { height:1px; background: var(--border); margin: 12px 0; }
</style>
</head>
<body>
<div class="wrap">
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
<div class="card">
<div class="row">
<div class="col">
<label>Issuer可选用于自动发现 /.well-known/openid-configuration</label>
<input id="issuer" placeholder="https://your-domain" />
<div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
<div class="muted">提示:若未配置 Issuer可直接填写下方端点。</div>
</div>
</div>
<div class="row">
<div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
<div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
</div>
<div class="row">
<div class="col"><label>UserInfo Endpoint可选</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
<div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
</div>
<div class="row">
<div class="col"><label>Redirect URI当前页地址或你的回调</label><input id="redirect_uri" /></div>
<div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
</div>
<div class="row">
<div class="col"><label>State</label><input id="state" /></div>
<div class="col"><label>Nonce</label><input id="nonce" /></div>
</div>
<div class="row">
<div class="col"><label>Code Verifier自动生成不会上送</label><input id="code_verifier" class="mono" readonly /></div>
<div class="col"><label>Code ChallengeS256</label><input id="code_challenge" class="mono" readonly /></div>
</div>
<div class="btns">
<button id="btnGenPkce">生成 PKCE</button>
<button id="btnRandomState">随机 State</button>
<button id="btnRandomNonce">随机 Nonce</button>
<button id="btnMakeAuthURL">生成授权链接</button>
<button id="btnAuthorize" class="primary">跳转授权</button>
</div>
<div class="row" style="margin-top:8px;">
<div class="col">
<label>授权链接(只生成不跳转)</label>
<textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
<div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
</div>
</div>
<div class="sep"></div>
<div class="muted">说明:
<ul>
<li>本页为纯前端演示,适用于公开客户端(不需要 client_secret</li>
<li>如跨域调用 Token/UserInfo需要服务端正确设置 CORS建议将此 demo 部署到同源域名下。</li>
</ul>
</div>
<div class="sep"></div>
<div class="row">
<div class="col">
<label>粘贴 OIDC Discovery JSON/.well-known/openid-configuration</label>
<textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
<div class="btns">
<button id="btnParseConf">解析并填充端点</button>
<button id="btnGenConf">用当前端点生成 JSON</button>
</div>
<div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
</div>
</div>
</div>
<div class="card">
<div class="row">
<div class="col">
<label>授权结果</label>
<div id="authResult" class="muted">等待授权...</div>
</div>
</div>
<div class="grid2" style="margin-top:12px;">
<div>
<label>Access Token</label>
<textarea id="access_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnCopyAT">复制</button>
<button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
</div>
<div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
</div>
<div>
<label>ID TokenJWT</label>
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnDecodeJWT">解码显示 Claims</button>
</div>
<pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
</div>
</div>
<div class="grid2" style="margin-top:12px;">
<div>
<label>Refresh Token</label>
<textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
</div>
</div>
<div>
<label>原始 Token 响应</label>
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function sha256B64Url(str){
const data = new TextEncoder().encode(str);
const digest = await crypto.subtle.digest('SHA-256', data);
return toB64Url(digest);
}
function randStr(len=64){
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const arr = new Uint8Array(len); crypto.getRandomValues(arr);
return Array.from(arr, v => chars[v % chars.length]).join('');
}
function setAuthInfo(msg, ok=true){
const el = $('authResult');
el.textContent = msg;
el.className = ok ? 'ok' : 'err';
}
function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
function persist(name, val){ sessionStorage.setItem('demo_'+name, val); }
function load(name){ return sessionStorage.getItem('demo_'+name) || ''; }
// init defaults
(function init(){
$('redirect_uri').value = window.location.origin + window.location.pathname;
// try load from discovery if issuer saved previously
const iss = load('issuer'); if(iss) $('issuer').value = iss;
const cid = load('client_id'); if(cid) $('client_id').value = cid;
const scp = load('scope'); if(scp) $('scope').value = scp;
})();
$('btnDiscover').onclick = async () => {
const iss = $('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; }
try{
persist('issuer', iss);
const res = await fetch(iss.replace(/\/$/,'') + '/api/.well-known/openid-configuration');
const d = await res.json();
$('authorization_endpoint').value = d.authorization_endpoint || '';
$('token_endpoint').value = d.token_endpoint || '';
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
$('conf_json').value = JSON.stringify(d, null, 2);
setAuthInfo('已从发现文档加载端点', true);
}catch(e){ setAuthInfo('自动发现失败:'+e, false); }
};
$('btnGenPkce').onclick = async () => {
const v = randStr(64); const c = await sha256B64Url(v);
$('code_verifier').value = v; $('code_challenge').value = c;
persist('code_verifier', v); persist('code_challenge', c);
setAuthInfo('已生成 PKCE 参数', true);
};
$('btnRandomState').onclick = () => { $('state').value = randStr(16); persist('state', $('state').value); };
$('btnRandomNonce').onclick = () => { $('nonce').value = randStr(16); persist('nonce', $('nonce').value); };
function buildAuthorizeURLFromFields() {
const auth = $('authorization_endpoint').value.trim();
const token = $('token_endpoint').value.trim(); // just validate
const cid = $('client_id').value.trim();
const red = $('redirect_uri').value.trim();
const scp = $('scope').value.trim() || 'openid profile email';
const st = $('state').value.trim() || randStr(16);
const no = $('nonce').value.trim() || randStr(16);
const cc = $('code_challenge').value.trim();
const cv = $('code_verifier').value.trim();
if(!auth || !token || !cid || !red){ throw new Error('请先完善端点/ClientID/RedirectURI'); }
if(!cc || !cv){ throw new Error('请先生成 PKCE'); }
persist('authorization_endpoint', auth); persist('token_endpoint', token);
persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp);
persist('state', st); persist('nonce', no); persist('code_verifier', cv);
const u = new URL(auth);
u.searchParams.set('response_type', 'code');
u.searchParams.set('client_id', cid);
u.searchParams.set('redirect_uri', red);
u.searchParams.set('scope', scp);
u.searchParams.set('state', st);
u.searchParams.set('nonce', no);
u.searchParams.set('code_challenge', cc);
u.searchParams.set('code_challenge_method', 'S256');
return u.toString();
}
$('btnMakeAuthURL').onclick = () => {
try {
const url = buildAuthorizeURLFromFields();
$('authorize_url').value = url;
setAuthInfo('已生成授权链接', true);
} catch(e){ setAuthInfo(e.message, false); }
};
$('btnAuthorize').onclick = () => {
try { const url = buildAuthorizeURLFromFields(); location.href = url; }
catch(e){ setAuthInfo(e.message, false); }
};
$('btnCopyAuthURL').onclick = async () => { try{ await navigator.clipboard.writeText($('authorize_url').value); }catch{} };
// Parse OIDC discovery JSON pasted by user
$('btnParseConf').onclick = () => {
const txt = $('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return; }
try{
const d = JSON.parse(txt);
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
if (d.authorization_endpoint) $('authorization_endpoint').value = d.authorization_endpoint;
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
if (d.userinfo_endpoint) $('userinfo_endpoint').value = d.userinfo_endpoint;
setAuthInfo('已解析配置并填充端点', true);
}catch(e){ setAuthInfo('解析失败:'+e, false); }
};
// Generate a minimal discovery JSON from current fields
$('btnGenConf').onclick = () => {
const d = {
issuer: $('issuer').value.trim() || undefined,
authorization_endpoint: $('authorization_endpoint').value.trim() || undefined,
token_endpoint: $('token_endpoint').value.trim() || undefined,
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
};
$('conf_json').value = JSON.stringify(d, null, 2);
};
async function postForm(url, data){
const body = Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/x-www-form-urlencoded' }, body });
if(!res.ok){ const t = await res.text(); throw new Error(`HTTP ${res.status} ${t}`); }
return res.json();
}
async function handleCallback(){
const code = qs('code'); const err = qs('error');
const state = qs('state');
if(err){ setAuthInfo('授权失败:'+err, false); return; }
if(!code){ setAuthInfo('等待授权...', true); return; }
// state check
if(state && load('state') && state !== load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; }
try{
const tokenEp = load('token_endpoint');
const data = await postForm(tokenEp, {
grant_type:'authorization_code',
code,
client_id: load('client_id'),
redirect_uri: load('redirect_uri'),
code_verifier: load('code_verifier')
});
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('授权成功,已获取令牌', true);
}catch(e){ setAuthInfo('交换令牌失败:'+e.message, false); }
}
handleCallback();
$('btnCopyAT').onclick = async () => { try{ await navigator.clipboard.writeText($('access_token').value); }catch{} };
$('btnDecodeJWT').onclick = () => {
const t = $('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return; }
const parts = t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return; }
try{ const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent = JSON.stringify(json, null, 2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; }
};
$('btnCallUserInfo').onclick = async () => {
const at = $('access_token').value.trim(); const ep = $('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return; }
try{
const res = await fetch(ep, { headers:{ Authorization: 'Bearer '+at } });
const data = await res.json(); $('userinfoOut').textContent = JSON.stringify(data, null, 2);
}catch(e){ $('userinfoOut').textContent = '调用失败:'+e; }
};
$('btnRefreshToken').onclick = async () => {
const rt = $('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return; }
try{
const tokenEp = load('token_endpoint');
const data = await postForm(tokenEp, {
grant_type:'refresh_token',
refresh_token: rt,
client_id: load('client_id')
});
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('刷新成功', true);
}catch(e){ setAuthInfo('刷新失败:'+e.message, false); }
};
</script>
</body>
</html>

View File

@@ -0,0 +1,181 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
func main() {
// 测试 Client Credentials 流程
//testClientCredentials()
// 测试 Authorization Code + PKCE 流程(需要浏览器交互)
testAuthorizationCode()
}
// testClientCredentials 测试服务对服务认证
func testClientCredentials() {
fmt.Println("=== Testing Client Credentials Flow ===")
cfg := clientcredentials.Config{
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
ClientSecret: "hLLdn2Ia4UM7hcsJaSuUFDV0Px9BrkNq",
TokenURL: "http://localhost:3000/api/oauth/token",
Scopes: []string{"api:read", "api:write"},
EndpointParams: map[string][]string{
"audience": {"api://new-api"},
},
}
// 创建HTTP客户端
httpClient := cfg.Client(context.Background())
// 调用受保护的API
resp, err := httpClient.Get("http://localhost:3000/api/status")
if err != nil {
log.Printf("Request failed: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Failed to read response: %v", err)
return
}
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Response: %s\n", string(body))
}
// testAuthorizationCode 测试授权码流程
func testAuthorizationCode() {
fmt.Println("=== Testing Authorization Code + PKCE Flow ===")
conf := oauth2.Config{
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
ClientSecret: "JHiugKf89OMmTLuZMZyA2sgZnO0Ioae3",
RedirectURL: "http://localhost:9999/callback",
// 包含 openid/profile/email 以便调用 UserInfo
Scopes: []string{"openid", "profile", "email", "api:read"},
Endpoint: oauth2.Endpoint{
AuthURL: "http://localhost:3000/api/oauth/authorize",
TokenURL: "http://localhost:3000/api/oauth/token",
},
}
// 生成PKCE参数
codeVerifier := oauth2.GenerateVerifier()
state := fmt.Sprintf("state-%d", time.Now().Unix())
// 构建授权URL
url := conf.AuthCodeURL(
state,
oauth2.S256ChallengeOption(codeVerifier),
//oauth2.SetAuthURLParam("audience", "api://new-api"),
)
fmt.Printf("Visit this URL to authorize:\n%s\n\n", url)
fmt.Printf("A local server will listen on http://localhost:9999/callback to receive the code...\n")
// 启动回调本地服务器,自动接收授权码
codeCh := make(chan string, 1)
srv := &http.Server{Addr: ":9999"}
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errParam := q.Get("error"); errParam != "" {
fmt.Fprintf(w, "Authorization failed: %s", errParam)
return
}
gotState := q.Get("state")
if gotState != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}
fmt.Fprintln(w, "Authorization received. You may close this window.")
select {
case codeCh <- code:
default:
}
go func() {
// 稍后关闭服务
_ = srv.Shutdown(context.Background())
}()
})
go func() {
_ = srv.ListenAndServe()
}()
// 等待授权码
var code string
select {
case code = <-codeCh:
case <-time.After(5 * time.Minute):
log.Println("Timeout waiting for authorization code")
_ = srv.Shutdown(context.Background())
return
}
// 交换令牌
token, err := conf.Exchange(
context.Background(),
code,
oauth2.VerifierOption(codeVerifier),
)
if err != nil {
log.Printf("Token exchange failed: %v", err)
return
}
fmt.Printf("Access Token: %s\n", token.AccessToken)
fmt.Printf("Token Type: %s\n", token.TokenType)
fmt.Printf("Expires In: %v\n", token.Expiry)
// 使用令牌调用 UserInfo
client := conf.Client(context.Background(), token)
userInfoURL := buildUserInfoFromAuth(conf.Endpoint.AuthURL)
resp, err := client.Get(userInfoURL)
if err != nil {
log.Printf("UserInfo request failed: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Failed to read UserInfo response: %v", err)
return
}
fmt.Printf("UserInfo: %s\n", string(body))
}
// buildUserInfoFromAuth 将授权端点URL转换为UserInfo端点URL
func buildUserInfoFromAuth(auth string) string {
u, err := url.Parse(auth)
if err != nil {
return ""
}
// 将最后一个路径段 authorize 替换为 userinfo
dir := path.Dir(u.Path)
if strings.HasSuffix(u.Path, "/authorize") {
u.Path = path.Join(dir, "userinfo")
} else {
// 回退:追加默认 /oauth/userinfo
u.Path = path.Join(dir, "userinfo")
}
return u.String()
}

24
go.mod
View File

@@ -11,19 +11,24 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0
github.com/aws/smithy-go v1.22.5
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
github.com/go-oauth2/gin-server v1.1.0
github.com/go-oauth2/oauth2/v4 v4.5.4
github.com/go-playground/validator/v10 v10.20.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.39.0
@@ -37,6 +42,7 @@ require (
golang.org/x/crypto v0.35.0
golang.org/x/image v0.23.0
golang.org/x/net v0.35.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.11.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
@@ -54,6 +60,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -64,7 +71,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
@@ -78,14 +85,25 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect
github.com/tidwall/buntdb v1.1.2 // indirect
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -93,7 +111,7 @@ 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/sys v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

96
go.sum
View File

@@ -1,5 +1,7 @@
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
@@ -23,8 +25,8 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6 h1:FCLDGi1EmB7JzjVVYNZiqc/zAJj2BQ5M0lfkVOxbfs8=
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6/go.mod h1:5FoAH5xUHHCMDvQPy1rnj8moqLkLHFaDVBjHhcFwEi0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -39,16 +41,22 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -67,6 +75,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/go-oauth2/gin-server v1.1.0 h1:+7AyIfrcKaThZxxABRYECysxAfTccgpFdAqY1enuzBk=
github.com/go-oauth2/gin-server v1.1.0/go.mod h1:f08F3l5/Pbayb4pjnv5PpUdQLFejgGfHrTjA6IZb0eM=
github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0=
github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -90,20 +102,26 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -112,6 +130,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -120,6 +140,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -130,6 +152,10 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
@@ -146,6 +172,18 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -158,6 +196,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -182,10 +222,18 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -198,21 +246,35 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.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/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI=
github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
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=
@@ -227,8 +289,24 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -245,6 +323,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
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/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
@@ -255,12 +335,12 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
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=
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

File diff suppressed because it is too large Load Diff

21
main.go
View File

@@ -14,6 +14,7 @@ import (
"one-api/router"
"one-api/service"
"one-api/setting/ratio_setting"
"one-api/src/oauth"
"os"
"strconv"
@@ -94,13 +95,9 @@ func main() {
}
go controller.AutomaticallyUpdateChannels(frequency)
}
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err != nil {
common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
}
go controller.AutomaticallyTestChannels(frequency)
}
go controller.AutomaticallyTestChannels()
if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk()
@@ -207,5 +204,13 @@ func InitResources() error {
if err != nil {
return err
}
// Initialize OAuth2 server
err = oauth.InitOAuthServer()
if err != nil {
common.SysLog("Warning: Failed to initialize OAuth2 server: " + err.Error())
// OAuth2 失败不应该阻止系统启动
}
return nil
}
}

View File

@@ -8,11 +8,14 @@ import (
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"one-api/src/oauth"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v5"
)
func validUserInfo(username string, role int) bool {
@@ -177,6 +180,7 @@ func WssAuth(c *gin.Context) {
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
rawAuth := c.Request.Header.Get("Authorization")
// 先检测是否为ws
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
@@ -235,6 +239,11 @@ func TokenAuth() func(c *gin.Context) {
}
}
if err != nil {
// OAuth Bearer fallback
if tryOAuthBearer(c, rawAuth) {
c.Next()
return
}
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
return
}
@@ -288,6 +297,74 @@ func TokenAuth() func(c *gin.Context) {
}
}
// tryOAuthBearer validates an OAuth JWT access token and sets minimal context for relay
func tryOAuthBearer(c *gin.Context, rawAuth string) bool {
if rawAuth == "" || !strings.HasPrefix(rawAuth, "Bearer ") {
return false
}
tokenString := strings.TrimSpace(strings.TrimPrefix(rawAuth, "Bearer "))
if tokenString == "" {
return false
}
settings := system_setting.GetOAuth2Settings()
// Parse & verify
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
if kid, ok := t.Header["kid"].(string); ok {
if settings.JWTKeyID != "" && kid != settings.JWTKeyID {
return nil, jwt.ErrTokenSignatureInvalid
}
}
pub := oauth.GetRSAPublicKey()
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err != nil || parsed == nil || !parsed.Valid {
return false
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return false
}
// issuer check when configured
if iss, ok2 := claims["iss"].(string); !ok2 || (settings.Issuer != "" && iss != settings.Issuer) {
return false
}
// revoke check
if jti, ok2 := claims["jti"].(string); ok2 && jti != "" {
if revoked, _ := model.IsTokenRevoked(jti); revoked {
return false
}
}
// scope check: must contain api:read or api:write or admin
scope, _ := claims["scope"].(string)
scopePadded := " " + scope + " "
if !(strings.Contains(scopePadded, " api:read ") || strings.Contains(scopePadded, " api:write ") || strings.Contains(scopePadded, " admin ")) {
return false
}
// subject must be user id to support quota logic
sub, _ := claims["sub"].(string)
uid, err := strconv.Atoi(sub)
if err != nil || uid <= 0 {
return false
}
// load user cache & set context
userCache, err := model.GetUserCache(uid)
if err != nil || userCache == nil || userCache.Status != common.UserStatusEnabled {
return false
}
c.Set("id", uid)
c.Set("group", userCache.Group)
c.Set("user_group", userCache.Group)
// set UsingGroup
common.SetContextKey(c, constant.ContextKeyUsingGroup, userCache.Group)
return true
}
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")

View File

@@ -0,0 +1,12 @@
package middleware
import "github.com/gin-gonic/gin"
func DisableCache() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Next()
}
}

View File

@@ -166,9 +166,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
@@ -185,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = modelName
}
c.Set("relay_mode", relayMode)
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
err = common.UnmarshalBodyReusable(c, &modelRequest)
}
if err != nil {
@@ -208,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
modelRequest.Model = c.PostForm("model")
}
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
relayMode := relayconstant.RelayModeAudioSpeech
@@ -248,6 +251,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride())
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
}

291
middleware/oauth_jwt.go Normal file
View File

@@ -0,0 +1,291 @@
package middleware
import (
"crypto/rsa"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting/system_setting"
"one-api/src/oauth"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// OAuthJWTAuth OAuth2 JWT认证中间件
func OAuthJWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查OAuth2是否启用
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.Next()
return
}
// 获取Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next() // 没有Authorization header继续到下一个中间件
return
}
// 检查是否为Bearer token
if !strings.HasPrefix(authHeader, "Bearer ") {
c.Next() // 不是Bearer token继续到下一个中间件
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == "" {
abortWithOAuthError(c, "invalid_token", "Missing token")
return
}
// 验证JWT token
claims, err := validateOAuthJWT(tokenString)
if err != nil {
abortWithOAuthError(c, "invalid_token", err.Error())
return
}
// 验证token的有效性
if err := validateOAuthClaims(claims); err != nil {
abortWithOAuthError(c, "invalid_token", err.Error())
return
}
// 设置上下文信息
setOAuthContext(c, claims)
c.Next()
}
}
// validateOAuthJWT 验证OAuth2 JWT令牌
func validateOAuthJWT(tokenString string) (jwt.MapClaims, error) {
// 解析JWT而不验证签名先获取header中的kid
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 检查签名方法
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// 获取kid
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing kid in token header")
}
// 根据kid获取公钥
publicKey, err := getPublicKeyByKid(kid)
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
return publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
// getPublicKeyByKid 根据kid获取公钥
func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) {
// 这里需要从JWKS获取公钥
// 在实际实现中你可能需要从OAuth server获取JWKS
// 这里先实现一个简单版本
// TODO: 实现JWKS缓存和刷新机制
pub := oauth.GetPublicKeyByKid(kid)
if pub == nil {
return nil, fmt.Errorf("unknown kid: %s", kid)
}
return pub, nil
}
// validateOAuthClaims 验证OAuth2 claims
func validateOAuthClaims(claims jwt.MapClaims) error {
settings := system_setting.GetOAuth2Settings()
// 验证issuer若配置了 Issuer 则强校验,否则仅要求存在)
if iss, ok := claims["iss"].(string); ok {
if settings.Issuer != "" && iss != settings.Issuer {
return fmt.Errorf("invalid issuer")
}
} else {
return fmt.Errorf("missing issuer claim")
}
// 验证audience
// if aud, ok := claims["aud"].(string); ok {
// // TODO: 验证audience
// }
// 验证客户端ID
if clientID, ok := claims["client_id"].(string); ok {
// 验证客户端是否存在且有效
client, err := model.GetOAuthClientByID(clientID)
if err != nil {
return fmt.Errorf("invalid client")
}
if client.Status != common.UserStatusEnabled {
return fmt.Errorf("client disabled")
}
// 检查是否被撤销
if jti, ok := claims["jti"].(string); ok && jti != "" {
revoked, _ := model.IsTokenRevoked(jti)
if revoked {
return fmt.Errorf("token revoked")
}
}
} else {
return fmt.Errorf("missing client_id claim")
}
return nil
}
// setOAuthContext 设置OAuth上下文信息
func setOAuthContext(c *gin.Context, claims jwt.MapClaims) {
c.Set("oauth_claims", claims)
c.Set("oauth_authenticated", true)
// 提取基本信息
if clientID, ok := claims["client_id"].(string); ok {
c.Set("oauth_client_id", clientID)
}
if scope, ok := claims["scope"].(string); ok {
c.Set("oauth_scope", scope)
}
if sub, ok := claims["sub"].(string); ok {
c.Set("oauth_subject", sub)
}
// 对于client_credentials流程subject就是client_id
// 对于authorization_code流程subject是用户ID
if grantType, ok := claims["grant_type"].(string); ok {
c.Set("oauth_grant_type", grantType)
}
}
// abortWithOAuthError 返回OAuth错误响应
func abortWithOAuthError(c *gin.Context, errorCode, description string) {
c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, errorCode, description))
c.JSON(http.StatusUnauthorized, gin.H{
"error": errorCode,
"error_description": description,
})
c.Abort()
}
// RequireOAuthScope OAuth2 scope验证中间件
func RequireOAuthScope(requiredScope string) gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否通过OAuth认证
if !c.GetBool("oauth_authenticated") {
abortWithOAuthError(c, "insufficient_scope", "OAuth2 authentication required")
return
}
// 获取token的scope
scope, exists := c.Get("oauth_scope")
if !exists {
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
return
}
scopeStr, ok := scope.(string)
if !ok {
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
return
}
// 检查是否包含所需的scope
scopes := strings.Split(scopeStr, " ")
for _, s := range scopes {
if strings.TrimSpace(s) == requiredScope {
c.Next()
return
}
}
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
}
}
// OptionalOAuthAuth 可选的OAuth认证中间件不会阻止请求
func OptionalOAuthAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 尝试OAuth认证但不会阻止请求
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := validateOAuthJWT(tokenString); err == nil {
if validateOAuthClaims(claims) == nil {
setOAuthContext(c, claims)
}
}
}
c.Next()
}
}
// RequireOAuthScopeIfPresent enforces scope only when OAuth is present; otherwise no-op
func RequireOAuthScopeIfPresent(requiredScope string) gin.HandlerFunc {
return func(c *gin.Context) {
if !c.GetBool("oauth_authenticated") {
c.Next()
return
}
scope, exists := c.Get("oauth_scope")
if !exists {
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
return
}
scopeStr, ok := scope.(string)
if !ok {
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
return
}
scopes := strings.Split(scopeStr, " ")
for _, s := range scopes {
if strings.TrimSpace(s) == requiredScope {
c.Next()
return
}
}
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
}
}
// GetOAuthClaims 获取OAuth claims
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
claims, exists := c.Get("oauth_claims")
if !exists {
return nil, false
}
mapClaims, ok := claims.(jwt.MapClaims)
return mapClaims, ok
}
// IsOAuthAuthenticated 检查是否通过OAuth认证
func IsOAuthAuthenticated(c *gin.Context) bool {
return c.GetBool("oauth_authenticated")
}

View File

@@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 增加活跃连接数
atomic.AddInt64(&globalStats.activeConnections, 1)
// 确保在请求结束时减少连接数
defer func() {
atomic.AddInt64(&globalStats.activeConnections, -1)
}()
c.Next()
}
}
@@ -38,4 +38,4 @@ func GetStats() StatsInfo {
return StatsInfo{
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
}
}
}

View File

@@ -42,13 +42,16 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
HeaderOverride *string `json:"header_override" gorm:"type:text"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
// add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置存储azure版本等不需要检索的信息详见dto.ChannelOtherSettings
// cache info
Keys []string `json:"-" gorm:"-"`
}
@@ -111,6 +114,10 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
}
lock := GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
statusList := channel.ChannelInfo.MultiKeyStatusList
// helper to get key status, default to enabled when missing
getStatus := func(idx int) int {
@@ -142,9 +149,6 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
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 {
@@ -246,6 +250,10 @@ func (channel *Channel) Save() error {
return DB.Save(channel).Error
}
func (channel *Channel) SaveWithoutKey() error {
return DB.Omit("key").Save(channel).Error
}
func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) {
var channels []*Channel
var err error
@@ -600,8 +608,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
return false
}
if channelCache.ChannelInfo.IsMultiKey {
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
// 如果是多Key模式更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
pollingLock.Unlock()
//CacheUpdateChannel(channelCache)
//return true
} else {
@@ -632,7 +644,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status
// Protect map writes with the same per-channel lock used by readers
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
handlerMultiKeyUpdate(channel, usingKey, status, reason)
pollingLock.Unlock()
if beforeStatus != channel.Status {
shouldUpdateAbilities = true
}
@@ -644,7 +660,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
channel.Status = status
shouldUpdateAbilities = true
}
err = channel.Save()
err = channel.SaveWithoutKey()
if err != nil {
common.SysLog(fmt.Sprintf("failed to update channel status: channel_id=%d, status=%d, error=%v", channel.Id, status, err))
return false
@@ -875,6 +891,17 @@ func (channel *Channel) GetParamOverride() map[string]interface{} {
return paramOverride
}
func (channel *Channel) GetHeaderOverride() map[string]interface{} {
headerOverride := make(map[string]interface{})
if channel.HeaderOverride != nil && *channel.HeaderOverride != "" {
err := common.Unmarshal([]byte(*channel.HeaderOverride), &headerOverride)
if err != nil {
common.SysLog(fmt.Sprintf("failed to unmarshal header override: channel_id=%d, error=%v", channel.Id, err))
}
}
return headerOverride
}
func GetChannelsByIds(ids []int) ([]*Channel, error) {
var channels []*Channel
err := DB.Where("id in (?)", ids).Find(&channels).Error

View File

@@ -64,22 +64,6 @@ var DB *gorm.DB
var LOG_DB *gorm.DB
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
func dropIndexIfExists(tableName string, indexName string) {
if !common.UsingMySQL {
return
}
var count int64
// Check index existence via information_schema
err := DB.Raw(
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
tableName, indexName,
).Scan(&count).Error
if err == nil && count > 0 {
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
}
}
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
@@ -263,16 +247,6 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE及早期命名方式防止与新复合唯一索引 (model_name, deleted_at) 冲突
dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称
dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称
//if !common.UsingPostgreSQL {
// return migrateDBFast()
//}
err := DB.AutoMigrate(
&Channel{},
&Token{},
@@ -291,6 +265,7 @@ func migrateDB() error {
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
&OAuthClient{},
)
if err != nil {
return err
@@ -299,13 +274,6 @@ func migrateDB() error {
}
func migrateDBFast() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE及早期命名方式防止与新复合唯一索引冲突
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("models", "model_name")
dropIndexIfExists("vendors", "uk_vendor_name")
dropIndexIfExists("vendors", "name")
var wg sync.WaitGroup

View File

@@ -20,17 +20,18 @@ type BoundChannel struct {
}
type Model struct {
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"`
SyncOfficial int `json:"sync_official" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`

183
model/oauth_client.go Normal file
View File

@@ -0,0 +1,183 @@
package model
import (
"encoding/json"
"one-api/common"
"strings"
"time"
"gorm.io/gorm"
)
// OAuthClient OAuth2 客户端模型
type OAuthClient struct {
ID string `json:"id" gorm:"type:varchar(64);primaryKey"`
Secret string `json:"secret" gorm:"type:varchar(128);not null"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Domain string `json:"domain" gorm:"type:varchar(255)"` // 允许的重定向域名
RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // JSON array of redirect URIs
GrantTypes string `json:"grant_types" gorm:"type:varchar(255);default:'client_credentials'"`
Scopes string `json:"scopes" gorm:"type:varchar(255);default:'api:read'"`
RequirePKCE bool `json:"require_pkce" gorm:"default:true"`
Status int `json:"status" gorm:"type:int;default:1"` // 1: enabled, 2: disabled
CreatedBy int `json:"created_by" gorm:"type:int;not null"` // 创建者用户ID
CreatedTime int64 `json:"created_time" gorm:"bigint"`
LastUsedTime int64 `json:"last_used_time" gorm:"bigint;default:0"`
TokenCount int `json:"token_count" gorm:"type:int;default:0"` // 已签发的token数量
Description string `json:"description" gorm:"type:text"`
ClientType string `json:"client_type" gorm:"type:varchar(32);default:'confidential'"` // confidential, public
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// GetRedirectURIs 获取重定向URI列表
func (c *OAuthClient) GetRedirectURIs() []string {
if c.RedirectURIs == "" {
return []string{}
}
var uris []string
err := json.Unmarshal([]byte(c.RedirectURIs), &uris)
if err != nil {
common.SysLog("failed to unmarshal redirect URIs: " + err.Error())
return []string{}
}
return uris
}
// SetRedirectURIs 设置重定向URI列表
func (c *OAuthClient) SetRedirectURIs(uris []string) {
data, err := json.Marshal(uris)
if err != nil {
common.SysLog("failed to marshal redirect URIs: " + err.Error())
return
}
c.RedirectURIs = string(data)
}
// GetGrantTypes 获取允许的授权类型列表
func (c *OAuthClient) GetGrantTypes() []string {
if c.GrantTypes == "" {
return []string{"client_credentials"}
}
return strings.Split(c.GrantTypes, ",")
}
// SetGrantTypes 设置允许的授权类型列表
func (c *OAuthClient) SetGrantTypes(types []string) {
c.GrantTypes = strings.Join(types, ",")
}
// GetScopes 获取允许的scope列表
func (c *OAuthClient) GetScopes() []string {
if c.Scopes == "" {
return []string{"api:read"}
}
return strings.Split(c.Scopes, ",")
}
// SetScopes 设置允许的scope列表
func (c *OAuthClient) SetScopes(scopes []string) {
c.Scopes = strings.Join(scopes, ",")
}
// ValidateRedirectURI 验证重定向URI是否有效
func (c *OAuthClient) ValidateRedirectURI(uri string) bool {
allowedURIs := c.GetRedirectURIs()
for _, allowedURI := range allowedURIs {
if allowedURI == uri {
return true
}
}
return false
}
// ValidateGrantType 验证授权类型是否被允许
func (c *OAuthClient) ValidateGrantType(grantType string) bool {
allowedTypes := c.GetGrantTypes()
for _, allowedType := range allowedTypes {
if allowedType == grantType {
return true
}
}
return false
}
// ValidateScope 验证scope是否被允许
func (c *OAuthClient) ValidateScope(scope string) bool {
allowedScopes := c.GetScopes()
requestedScopes := strings.Split(scope, " ")
for _, requestedScope := range requestedScopes {
requestedScope = strings.TrimSpace(requestedScope)
if requestedScope == "" {
continue
}
found := false
for _, allowedScope := range allowedScopes {
if allowedScope == requestedScope {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// BeforeCreate GORM hook - 在创建前设置时间
func (c *OAuthClient) BeforeCreate(tx *gorm.DB) (err error) {
c.CreatedTime = time.Now().Unix()
return
}
// UpdateLastUsedTime 更新最后使用时间
func (c *OAuthClient) UpdateLastUsedTime() error {
c.LastUsedTime = time.Now().Unix()
c.TokenCount++
return DB.Model(c).Select("last_used_time", "token_count").Updates(c).Error
}
// GetOAuthClientByID 根据ID获取OAuth客户端
func GetOAuthClientByID(id string) (*OAuthClient, error) {
var client OAuthClient
err := DB.Where("id = ? AND status = ?", id, common.UserStatusEnabled).First(&client).Error
return &client, err
}
// GetAllOAuthClients 获取所有OAuth客户端
func GetAllOAuthClients(startIdx int, num int) ([]*OAuthClient, error) {
var clients []*OAuthClient
err := DB.Order("created_time desc").Limit(num).Offset(startIdx).Find(&clients).Error
return clients, err
}
// SearchOAuthClients 搜索OAuth客户端
func SearchOAuthClients(keyword string) ([]*OAuthClient, error) {
var clients []*OAuthClient
err := DB.Where("name LIKE ? OR id LIKE ? OR description LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").Find(&clients).Error
return clients, err
}
// CreateOAuthClient 创建OAuth客户端
func CreateOAuthClient(client *OAuthClient) error {
return DB.Create(client).Error
}
// UpdateOAuthClient 更新OAuth客户端
func UpdateOAuthClient(client *OAuthClient) error {
return DB.Save(client).Error
}
// DeleteOAuthClient 删除OAuth客户端
func DeleteOAuthClient(id string) error {
return DB.Where("id = ?", id).Delete(&OAuthClient{}).Error
}
// CountOAuthClients 统计OAuth客户端数量
func CountOAuthClients() (int64, error) {
var count int64
err := DB.Model(&OAuthClient{}).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,57 @@
package model
import (
"fmt"
"one-api/common"
"sync"
"time"
)
var revokedMem sync.Map // jti -> exp(unix)
func RevokeToken(jti string, exp int64) error {
if jti == "" {
return nil
}
// Prefer Redis, else in-memory
if common.RedisEnabled {
ttl := time.Duration(0)
if exp > 0 {
ttl = time.Until(time.Unix(exp, 0))
}
if ttl <= 0 {
ttl = time.Minute
}
key := fmt.Sprintf("oauth:revoked:%s", jti)
return common.RedisSet(key, "1", ttl)
}
if exp <= 0 {
exp = time.Now().Add(time.Minute).Unix()
}
revokedMem.Store(jti, exp)
return nil
}
func IsTokenRevoked(jti string) (bool, error) {
if jti == "" {
return false, nil
}
if common.RedisEnabled {
key := fmt.Sprintf("oauth:revoked:%s", jti)
if _, err := common.RedisGet(key); err == nil {
return true, nil
} else {
// Not found or error; treat as not revoked on error to avoid hard failures
return false, nil
}
}
// In-memory check
if v, ok := revokedMem.Load(jti); ok {
exp, _ := v.(int64)
if exp == 0 || time.Now().Unix() <= exp {
return true, nil
}
revokedMem.Delete(jti)
}
return false, nil
}

View File

@@ -6,6 +6,7 @@ import (
"one-api/setting/config"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"strconv"
"strings"
"time"
@@ -66,16 +67,16 @@ func InitOptionMap() {
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
common.OptionMap["WorkerUrl"] = setting.WorkerUrl
common.OptionMap["WorkerValidKey"] = setting.WorkerValidKey
common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(setting.WorkerAllowHttpImageRequestEnabled)
common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
common.OptionMap["PayAddress"] = ""
common.OptionMap["CustomCallbackAddress"] = ""
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["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64)
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64)
common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp)
common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp)
common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
@@ -85,7 +86,7 @@ func InitOptionMap() {
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["PayMethods"] = setting.PayMethods2JsonString()
common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["TelegramBotToken"] = ""
@@ -271,7 +272,7 @@ func updateOptionMap(key string, value string) (err error) {
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "WorkerAllowHttpImageRequestEnabled":
setting.WorkerAllowHttpImageRequestEnabled = boolValue
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
case "ExposeRatioEnabled":
@@ -293,29 +294,29 @@ func updateOptionMap(key string, value string) (err error) {
case "SMTPToken":
common.SMTPToken = value
case "ServerAddress":
setting.ServerAddress = value
system_setting.ServerAddress = value
case "WorkerUrl":
setting.WorkerUrl = value
system_setting.WorkerUrl = value
case "WorkerValidKey":
setting.WorkerValidKey = value
system_setting.WorkerValidKey = value
case "PayAddress":
setting.PayAddress = value
operation_setting.PayAddress = value
case "Chats":
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "CustomCallbackAddress":
setting.CustomCallbackAddress = value
operation_setting.CustomCallbackAddress = value
case "EpayId":
setting.EpayId = value
operation_setting.EpayId = value
case "EpayKey":
setting.EpayKey = value
operation_setting.EpayKey = value
case "Price":
setting.Price, _ = strconv.ParseFloat(value, 64)
operation_setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
case "MinTopUp":
setting.MinTopUp, _ = strconv.Atoi(value)
operation_setting.MinTopUp, _ = strconv.Atoi(value)
case "StripeApiSecret":
setting.StripeApiSecret = value
case "StripeWebhookSecret":
@@ -413,7 +414,7 @@ func updateOptionMap(key string, value string) (err error) {
case "StreamCacheQueueLength":
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
err = setting.UpdatePayMethodsByJsonString(value)
err = operation_setting.UpdatePayMethodsByJsonString(value)
}
return err
}

View File

@@ -155,9 +155,12 @@ func updatePricing() {
vendorMap[vendors[i].Id] = &vendors[i]
}
// 初始化默认供应商映射
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)
// 构建对前端友好的供应商列表
vendorsList = make([]PricingVendor, 0, len(vendors))
for _, v := range vendors {
vendorsList = make([]PricingVendor, 0, len(vendorMap))
for _, v := range vendorMap {
vendorsList = append(vendorsList, PricingVendor{
ID: v.Id,
Name: v.Name,

128
model/pricing_default.go Normal file
View File

@@ -0,0 +1,128 @@
package model
import (
"strings"
)
// 简化的供应商映射规则
var defaultVendorRules = map[string]string{
"gpt": "OpenAI",
"dall-e": "OpenAI",
"whisper": "OpenAI",
"o1": "OpenAI",
"o3": "OpenAI",
"claude": "Anthropic",
"gemini": "Google",
"moonshot": "Moonshot",
"kimi": "Moonshot",
"chatglm": "智谱",
"glm-": "智谱",
"qwen": "阿里巴巴",
"deepseek": "DeepSeek",
"abab": "MiniMax",
"ernie": "百度",
"spark": "讯飞",
"hunyuan": "腾讯",
"command": "Cohere",
"@cf/": "Cloudflare",
"360": "360",
"yi": "零一万物",
"jina": "Jina",
"mistral": "Mistral",
"grok": "xAI",
"llama": "Meta",
"doubao": "字节跳动",
"kling": "快手",
"jimeng": "即梦",
"vidu": "Vidu",
}
// 供应商默认图标映射
var defaultVendorIcons = map[string]string{
"OpenAI": "OpenAI",
"Anthropic": "Claude.Color",
"Google": "Gemini.Color",
"Moonshot": "Moonshot",
"智谱": "Zhipu.Color",
"阿里巴巴": "Qwen.Color",
"DeepSeek": "DeepSeek.Color",
"MiniMax": "Minimax.Color",
"百度": "Wenxin.Color",
"讯飞": "Spark.Color",
"腾讯": "Hunyuan.Color",
"Cohere": "Cohere.Color",
"Cloudflare": "Cloudflare.Color",
"360": "Ai360.Color",
"零一万物": "Yi.Color",
"Jina": "Jina",
"Mistral": "Mistral.Color",
"xAI": "XAI",
"Meta": "Ollama",
"字节跳动": "Doubao.Color",
"快手": "Kling.Color",
"即梦": "Jimeng.Color",
"Vidu": "Vidu",
"微软": "AzureAI",
"Microsoft": "AzureAI",
"Azure": "AzureAI",
}
// initDefaultVendorMapping 简化的默认供应商映射
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
for _, ability := range enableAbilities {
modelName := ability.Model
if _, exists := metaMap[modelName]; exists {
continue
}
// 匹配供应商
vendorID := 0
modelLower := strings.ToLower(modelName)
for pattern, vendorName := range defaultVendorRules {
if strings.Contains(modelLower, pattern) {
vendorID = getOrCreateVendor(vendorName, vendorMap)
break
}
}
// 创建模型元数据
metaMap[modelName] = &Model{
ModelName: modelName,
VendorID: vendorID,
Status: 1,
NameRule: NameRuleExact,
}
}
}
// 查找或创建供应商
func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int {
// 查找现有供应商
for id, vendor := range vendorMap {
if vendor.Name == vendorName {
return id
}
}
// 创建新供应商
newVendor := &Vendor{
Name: vendorName,
Status: 1,
Icon: getDefaultVendorIcon(vendorName),
}
if err := newVendor.Insert(); err != nil {
return 0
}
vendorMap[newVendor.Id] = newVendor
return newVendor.Id
}
// 获取供应商默认图标
func getDefaultVendorIcon(vendorName string) string {
if icon, exists := defaultVendorIcons[vendorName]; exists {
return icon
}
return ""
}

View File

@@ -77,7 +77,7 @@ type SyncTaskQueryParams struct {
UserIDs []int
}
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.TaskRelayInfo) *Task {
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
t := &Task{
UserId: relayInfo.UserId,
SubmitTime: time.Now().Unix(),

View File

@@ -16,7 +16,7 @@ type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"unique;not null;index"`
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥不返回给前端
IsEnabled bool `json:"is_enabled" gorm:"default:false"`
IsEnabled bool `json:"is_enabled"`
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
LockedUntil *time.Time `json:"locked_until,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
@@ -30,7 +30,7 @@ type TwoFABackupCode struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;index"`
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
IsUsed bool `json:"is_used" gorm:"default:false"`
IsUsed bool `json:"is_used"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`

View File

@@ -91,6 +91,68 @@ func (user *User) SetSetting(setting dto.UserSetting) {
user.Setting = string(settingBytes)
}
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfigForRole(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil
func CheckUserExistOrDeleted(username string, email string) (bool, error) {
var user User
@@ -320,10 +382,34 @@ func (user *User) Insert(inviterId int) error {
user.Quota = common.QuotaForNewUser
//user.SetAccessToken(common.GetUUID())
user.AffCode = common.GetRandomString(4)
// 初始化用户设置,包括默认的边栏配置
if user.Setting == "" {
defaultSetting := dto.UserSetting{}
// 这里暂时不设置SidebarModules因为需要在用户创建后根据角色设置
user.SetSetting(defaultSetting)
}
result := DB.Create(user)
if result.Error != nil {
return result.Error
}
// 用户创建成功后,根据角色初始化边栏配置
// 需要重新获取用户以确保有正确的ID和Role
var createdUser User
if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil {
// 生成基于角色的默认边栏配置
defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
if defaultSidebarConfig != "" {
currentSetting := createdUser.GetSetting()
currentSetting.SidebarModules = defaultSidebarConfig
createdUser.SetSetting(currentSetting)
createdUser.Update(false)
common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role))
}
}
if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
}

View File

@@ -14,13 +14,13 @@ import (
type Vendor struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"`
}
// Insert 创建新的供应商记录

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
@@ -16,12 +17,17 @@ import (
func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
audioRequest, ok := info.Request.(*dto.AudioRequest)
audioReq, ok := info.Request.(*dto.AudioRequest)
if !ok {
return types.NewError(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
err := helper.ModelMappedHelper(c, info, audioRequest)
request, err := common.DeepCopy(audioReq)
if err != nil {
return types.NewError(fmt.Errorf("failed to copy request to AudioRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
@@ -32,7 +38,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
}
adaptor.Init(info)
ioReader, err := adaptor.ConvertAudioRequest(c, info, *audioRequest)
ioReader, err := adaptor.ConvertAudioRequest(c, info, *request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
@@ -47,7 +53,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false)
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError

View File

@@ -30,16 +30,16 @@ type Adaptor interface {
}
type TaskAdaptor interface {
Init(info *relaycommon.TaskRelayInfo)
Init(info *relaycommon.RelayInfo)
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError
BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error)
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error
BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error)
BuildRequestURL(info *relaycommon.RelayInfo) (string, error)
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error
BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error)
DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
GetModelList() []string
GetChannelName() string

View File

@@ -3,7 +3,6 @@ package ali
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
@@ -14,6 +13,8 @@ import (
"one-api/relay/constant"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
@@ -44,6 +45,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
case constant.RelayModeImagesEdits:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
default:
@@ -63,6 +66,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
if c.GetString("plugin") != "" {
req.Set("X-DashScope-Plugin", c.GetString("plugin"))
}
if info.RelayMode == constant.RelayModeImagesGenerations {
req.Set("X-DashScope-Async", "enable")
}
if info.RelayMode == constant.RelayModeImagesEdits {
req.Set("Content-Type", "application/json")
}
return nil
}
@@ -90,8 +99,30 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
aliRequest := oaiImage2Ali(request)
return aliRequest, nil
if info.RelayMode == constant.RelayModeImagesGenerations {
aliRequest, err := oaiImage2Ali(request)
if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err)
}
return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits {
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
// 如果用户使用表单,则需要解析表单数据
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
if err != nil {
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
}
return aliRequest, nil
} else {
aliRequest, err := oaiImage2Ali(request)
if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err)
}
return aliRequest, nil
}
}
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -120,15 +151,24 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
switch info.RelayFormat {
case types.RelayFormatClaude:
if info.IsStream {
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
} else {
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
default:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeImagesEdits:
err, usage = aliImageEditHandler(c, resp, info)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
adaptor := openai.Adaptor{}
usage, err = adaptor.DoResponse(c, resp, info)
}
return usage, err
}
return
}
func (a *Adaptor) GetModelList() []string {

View File

@@ -3,10 +3,15 @@ package ali
import "one-api/dto"
type AliMessage struct {
Content string `json:"content"`
Content any `json:"content"`
Role string `json:"role"`
}
type AliMediaContent struct {
Image string `json:"image,omitempty"`
Text string `json:"text,omitempty"`
}
type AliInput struct {
Prompt string `json:"prompt,omitempty"`
//History []AliMessage `json:"history,omitempty"`
@@ -70,13 +75,14 @@ type TaskResult struct {
}
type AliOutput struct {
TaskId string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"`
TaskId string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"`
Choices []map[string]any `json:"choices,omitempty"`
}
type AliResponse struct {
@@ -86,20 +92,26 @@ type AliResponse struct {
}
type AliImageRequest struct {
Model string `json:"model"`
Input struct {
Prompt string `json:"prompt"`
NegativePrompt string `json:"negative_prompt,omitempty"`
} `json:"input"`
Parameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
} `json:"parameters,omitempty"`
Model string `json:"model"`
Input any `json:"input"`
Parameters any `json:"parameters,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
}
type AliImageInput struct {
Prompt string `json:"prompt,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Messages []AliMessage `json:"messages,omitempty"`
}
type AliRerankParameters struct {
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`

View File

@@ -1,10 +1,12 @@
package ali
import (
"encoding/json"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"one-api/common"
"one-api/dto"
@@ -18,15 +20,135 @@ import (
"github.com/gin-gonic/gin"
)
func oaiImage2Ali(request dto.ImageRequest) *AliImageRequest {
func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra)
if request.Extra != nil {
if val, ok := request.Extra["parameters"]; ok {
err := common.Unmarshal(val, &imageRequest.Parameters)
if err != nil {
return nil, fmt.Errorf("invalid parameters field: %w", err)
}
}
if val, ok := request.Extra["input"]; ok {
err := common.Unmarshal(val, &imageRequest.Input)
if err != nil {
return nil, fmt.Errorf("invalid input field: %w", err)
}
}
}
if imageRequest.Parameters == nil {
imageRequest.Parameters = AliImageParameters{
Size: strings.Replace(request.Size, "x", "*", -1),
N: int(request.N),
Watermark: request.Watermark,
}
}
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Prompt: request.Prompt,
}
}
return &imageRequest, nil
}
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Input.Prompt = request.Prompt
imageRequest.Model = request.Model
imageRequest.Parameters.Size = strings.Replace(request.Size, "x", "*", -1)
imageRequest.Parameters.N = int(request.N)
imageRequest.ResponseFormat = request.ResponseFormat
return &imageRequest
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
mf = c.Request.MultipartForm
}
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range mf.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
imageFiles = append(imageFiles, files...)
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
if len(imageFiles) == 0 {
return nil, errors.New("image is required")
}
if len(imageFiles) > 1 {
return nil, errors.New("only one image is supported for qwen edit")
}
// 获取base64编码的图片
var imageBase64s []string
for _, file := range imageFiles {
image, err := file.Open()
if err != nil {
return nil, errors.New("failed to open image file")
}
// 读取文件内容
imageData, err := io.ReadAll(image)
if err != nil {
return nil, errors.New("failed to read image file")
}
// 获取MIME类型
mimeType := http.DetectContentType(imageData)
// 编码为base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
// 构造data URL格式
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
imageBase64s = append(imageBase64s, dataURL)
image.Close()
}
//dto.MediaContent{}
mediaContents := make([]AliMediaContent, len(imageBase64s))
for i, b64 := range imageBase64s {
mediaContents[i] = AliMediaContent{
Image: b64,
}
}
mediaContents = append(mediaContents, AliMediaContent{
Text: request.Prompt,
})
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: mediaContents,
},
},
}
imageRequest.Parameters = AliImageParameters{
Watermark: request.Watermark,
}
return &imageRequest, nil
}
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
@@ -52,7 +174,7 @@ func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error
responseBody, err := io.ReadAll(resp.Body)
var response AliResponse
err = json.Unmarshal(responseBody, &response)
err = common.Unmarshal(responseBody, &response)
if err != nil {
common.SysLog("updateTask NewDecoder err: " + err.Error())
return &aliResponse, err, nil
@@ -61,8 +183,8 @@ func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error
return &response, nil, responseBody
}
func asyncTaskWait(info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) {
waitSeconds := 3
func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) {
waitSeconds := 10
step := 0
maxStep := 20
@@ -70,11 +192,14 @@ func asyncTaskWait(info *relaycommon.RelayInfo, taskID string) (*AliResponse, []
var responseBody []byte
for {
logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds))
step++
rsp, err, body := updateTask(info, taskID)
responseBody = body
if err != nil {
return &taskResponse, responseBody, err
logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error())
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
if rsp.Output.TaskStatus == "" {
@@ -100,7 +225,7 @@ func asyncTaskWait(info *relaycommon.RelayInfo, taskID string) (*AliResponse, []
return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout")
}
func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse {
func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody []byte, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse {
imageResponse := dto.ImageResponse{
Created: info.StartTime.Unix(),
}
@@ -124,6 +249,9 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, info *relayc
RevisedPrompt: "",
})
}
var mapResponse map[string]any
_ = common.Unmarshal(originBody, &mapResponse)
imageResponse.Extra = mapResponse
return &imageResponse
}
@@ -136,7 +264,7 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &aliTaskResponse)
err = common.Unmarshal(responseBody, &aliTaskResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
@@ -146,7 +274,7 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
}
aliResponse, _, err := asyncTaskWait(info, aliTaskResponse.Output.TaskId)
aliResponse, originRespBody, err := asyncTaskWait(c, info, aliTaskResponse.Output.TaskId)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponse), nil
}
@@ -160,13 +288,52 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
}, resp.StatusCode), nil
}
fullTextResponse := responseAli2OpenAIImage(c, aliResponse, info, responseFormat)
jsonResponse, err := json.Marshal(fullTextResponse)
fullTextResponse := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
jsonResponse, err := common.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
c.Writer.Write(jsonResponse)
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{}
}
func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
var aliResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = common.Unmarshal(responseBody, &aliResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliResponse.Message != "" {
logger.LogError(c, "ali_task_failed: "+aliResponse.Message)
return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil
}
var fullTextResponse dto.ImageResponse
if len(aliResponse.Output.Choices) > 0 {
fullTextResponse = dto.ImageResponse{
Created: info.StartTime.Unix(),
Data: []dto.ImageData{
{
Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string),
B64Json: "",
},
},
}
}
var mapResponse map[string]any
_ = common.Unmarshal(responseBody, &mapResponse)
fullTextResponse.Extra = mapResponse
jsonResponse, err := common.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{}
}

View File

@@ -13,6 +13,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting/operation_setting"
"one-api/types"
"sync"
"time"
@@ -47,7 +48,19 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
err = a.SetupRequestHeader(c, &req.Header, info)
headers := req.Header
headerOverride := make(map[string]string)
for k, v := range info.HeadersOverride {
if str, ok := v.(string); ok {
headerOverride[k] = str
} else {
return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid)
}
}
for key, value := range headerOverride {
headers.Set(key, value)
}
err = a.SetupRequestHeader(c, &headers, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
@@ -72,8 +85,19 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
}
// set form data
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
err = a.SetupRequestHeader(c, &req.Header, info)
headers := req.Header
headerOverride := make(map[string]string)
for k, v := range info.HeadersOverride {
if str, ok := v.(string); ok {
headerOverride[k] = str
} else {
return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid)
}
}
for key, value := range headerOverride {
headers.Set(key, value)
}
err = a.SetupRequestHeader(c, &headers, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
@@ -240,9 +264,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
resp, err := client.Do(req)
if err != nil {
return nil, err
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
}
if resp == nil {
return nil, errors.New("resp is nil")
@@ -253,7 +276,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
return resp, nil
}
func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
fullRequestURL, err := a.BuildRequestURL(info)
if err != nil {
return nil, err
@@ -270,7 +293,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo,
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
resp, err := doRequest(c, req, info.RelayInfo)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
}

View File

@@ -60,7 +60,16 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
// 检查是否为Nova模型
if isNovaModel(request.Model) {
novaReq := convertToNovaRequest(request)
c.Set("request_model", request.Model)
c.Set("converted_request", novaReq)
c.Set("is_nova_model", true)
return novaReq, nil
}
// 原有的Claude模型处理逻辑
var claudeReq *dto.ClaudeRequest
var err error
claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request)
@@ -69,6 +78,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
c.Set("request_model", claudeReq.Model)
c.Set("converted_request", claudeReq)
c.Set("is_nova_model", false)
return claudeReq, err
}

View File

@@ -1,5 +1,7 @@
package aws
import "strings"
var awsModelIDMap = map[string]string{
"claude-instant-1.2": "anthropic.claude-instant-v1",
"claude-2.0": "anthropic.claude-v2",
@@ -14,6 +16,11 @@ var awsModelIDMap = map[string]string{
"claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0",
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -58,7 +65,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"anthropic.claude-opus-4-1-20250805-v1:0": {
"us": true,
},
}
// Nova models - all support three major regions
"amazon.nova-micro-v1:0": {
"us": true,
"eu": true,
"apac": true,
},
"amazon.nova-lite-v1:0": {
"us": true,
"eu": true,
"apac": true,
},
"amazon.nova-pro-v1:0": {
"us": true,
"eu": true,
"apac": true,
},
"amazon.nova-premier-v1:0": {
"us": true,
"eu": true,
"apac": true,
}}
var awsRegionCrossModelPrefixMap = map[string]string{
"us": "us",
@@ -67,3 +94,8 @@ var awsRegionCrossModelPrefixMap = map[string]string{
}
var ChannelName = "aws"
// 判断是否为Nova模型
func isNovaModel(modelId string) bool {
return strings.HasPrefix(modelId, "nova-")
}

View File

@@ -34,3 +34,92 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
Thinking: req.Thinking,
}
}
// NovaMessage Nova模型使用messages-v1格式
type NovaMessage struct {
Role string `json:"role"`
Content []NovaContent `json:"content"`
}
type NovaContent struct {
Text string `json:"text"`
}
type NovaRequest struct {
SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0"
Messages []NovaMessage `json:"messages"` // 对话消息列表
InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选
}
type NovaInferenceConfig struct {
MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数
Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1)
TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1)
TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128)
StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列
}
// 转换OpenAI请求为Nova格式
func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
novaMessages := make([]NovaMessage, len(req.Messages))
for i, msg := range req.Messages {
novaMessages[i] = NovaMessage{
Role: msg.Role,
Content: []NovaContent{{Text: msg.StringContent()}},
}
}
novaReq := &NovaRequest{
SchemaVersion: "messages-v1",
Messages: novaMessages,
}
// 设置推理配置
if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil {
novaReq.InferenceConfig = &NovaInferenceConfig{}
if req.MaxTokens != 0 {
novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens)
}
if req.Temperature != nil && *req.Temperature != 0 {
novaReq.InferenceConfig.Temperature = *req.Temperature
}
if req.TopP != 0 {
novaReq.InferenceConfig.TopP = req.TopP
}
if req.TopK != 0 {
novaReq.InferenceConfig.TopK = req.TopK
}
if req.Stop != nil {
if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 {
novaReq.InferenceConfig.StopSequences = stopSequences
}
}
}
return novaReq
}
// parseStopSequences 解析停止序列,支持字符串或字符串数组
func parseStopSequences(stop any) []string {
if stop == nil {
return nil
}
switch v := stop.(type) {
case string:
if v != "" {
return []string{v}
}
case []string:
return v
case []interface{}:
var sequences []string
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
sequences = append(sequences, str)
}
}
return sequences
}
return nil
}

View File

@@ -1,6 +1,7 @@
package aws
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
@@ -93,7 +94,19 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
}
awsModelId := awsModelID(c.GetString("request_model"))
// 检查是否为Nova模型
isNova, _ := c.Get("is_nova_model")
if isNova == true {
// Nova模型也支持跨区域
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
}
return handleNovaRequest(c, awsCli, info, awsModelId)
}
// 原有的Claude处理逻辑
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
@@ -130,7 +143,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
Usage: &dto.Usage{},
}
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage)
// 复制上游 Content-Type 到客户端响应头
if awsResp.ContentType != nil && *awsResp.ContentType != "" {
c.Writer.Header().Set("Content-Type", *awsResp.ContentType)
}
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, RequestModeMessage)
if handlerErr != nil {
return handlerErr, nil
}
@@ -204,3 +222,74 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage)
return nil, claudeInfo.Usage
}
// Nova模型处理函数
func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) {
novaReq_, ok := c.Get("converted_request")
if !ok {
return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil
}
novaReq := novaReq_.(*NovaRequest)
// 使用InvokeModel API但使用Nova格式的请求体
awsReq := &bedrockruntime.InvokeModelInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
reqBody, err := json.Marshal(novaReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil
}
awsReq.Body = reqBody
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
if err != nil {
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
}
// 解析Nova响应
var novaResp struct {
Output struct {
Message struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"message"`
} `json:"output"`
Usage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
TotalTokens int `json:"totalTokens"`
} `json:"usage"`
}
if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil {
return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil
}
// 构造OpenAI格式响应
response := dto.OpenAITextResponse{
Id: helper.GetResponseID(c),
Object: "chat.completion",
Created: common.GetTimestamp(),
Model: info.UpstreamModelName,
Choices: []dto.OpenAITextResponseChoice{{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: novaResp.Output.Message.Content[0].Text,
},
FinishReason: "stop",
}},
Usage: dto.Usage{
PromptTokens: novaResp.Usage.InputTokens,
CompletionTokens: novaResp.Usage.OutputTokens,
TotalTokens: novaResp.Usage.TotalTokens,
},
}
c.JSON(http.StatusOK, response)
return nil, &response.Usage
}

View File

@@ -81,20 +81,23 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasSuffix(info.UpstreamModelName, "-search") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
request.Model = info.UpstreamModelName
toMap := request.ToMap()
toMap["web_search"] = map[string]any{
"enable": true,
"enable_citation": true,
"enable_trace": true,
"enable_status": false,
if len(request.WebSearch) == 0 {
toMap := request.ToMap()
toMap["web_search"] = map[string]any{
"enable": true,
"enable_citation": true,
"enable_trace": true,
"enable_status": false,
}
return toMap, nil
}
return toMap, nil
return request, nil
}
return request, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, nil
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {

View File

@@ -102,9 +102,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
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)
return ClaudeStreamHandler(c, resp, info, a.RequestMode)
} else {
err, usage = ClaudeHandler(c, resp, info, a.RequestMode)
return ClaudeHandler(c, resp, info, a.RequestMode)
}
return
}

View File

@@ -32,7 +32,7 @@ func stopReasonClaude2OpenAI(reason string) string {
case "end_turn":
return "stop"
case "max_tokens":
return "max_tokens"
return "length"
case "tool_use":
return "tool_calls"
default:
@@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
// 初始化system消息数组用于累积多个system消息
var systemMessages []dto.ClaudeMediaMessage
for _, message := range formatMessages {
if message.Role == "system" {
// 根据Claude API规范system字段使用数组格式更有通用性
if message.IsStringContent() {
claudeRequest.System = message.StringContent()
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](message.StringContent()),
})
} else {
contents := message.ParseContent()
content := ""
for _, ctx := range contents {
// 支持复合内容的system消息虽然不常见但需要考虑完整性
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" {
content += ctx.Text
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](ctx.Text),
})
}
// 未来可以在这里扩展对图片等其他类型的支持
}
claudeRequest.System = content
}
} else {
if isFirstMessage {
@@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages = append(claudeMessages, claudeMessage)
}
}
// 设置累积的system消息
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
return &claudeRequest, nil
@@ -426,7 +441,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil {
//choice.Delta.SetContentString(claudeResponse.ContentBlock.Text)
// 如果是文本块,尽可能发送首段文本(若存在)
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: common.GetPointer(fcIdx),
@@ -674,7 +692,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
}
}
func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) {
claudeInfo := &ClaudeResponseInfo{
ResponseId: helper.GetResponseID(c),
Created: common.GetTimestamp(),
@@ -691,14 +709,14 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
return true
})
if err != nil {
return err, nil
return nil, err
}
HandleStreamFinalResponse(c, info, claudeInfo, requestMode)
return nil, claudeInfo.Usage
return claudeInfo.Usage, nil
}
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError {
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, data []byte, requestMode int) *types.NewAPIError {
var claudeResponse dto.ClaudeResponse
err := common.Unmarshal(data, &claudeResponse)
if err != nil {
@@ -736,11 +754,11 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
service.IOCopyBytesGracefully(c, nil, responseData)
service.IOCopyBytesGracefully(c, httpResp, responseData)
return nil
}
func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
claudeInfo := &ClaudeResponseInfo{
@@ -752,16 +770,16 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if common.DebugEnabled {
println("responseBody: ", string(responseBody))
}
handleErr := HandleClaudeResponseData(c, info, claudeInfo, responseBody, requestMode)
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode)
if handleErr != nil {
return handleErr, nil
return nil, handleErr
}
return nil, claudeInfo.Usage
return claudeInfo.Usage, nil
}
func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {

View File

@@ -749,7 +749,16 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
var texts []string
var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts {
if part.FunctionCall != nil {
if part.InlineData != nil {
// 媒体内容
if strings.HasPrefix(part.InlineData.MimeType, "image") {
imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")"
texts = append(texts, imgText)
} else {
// 其他媒体类型,直接显示链接
texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data))
}
} else if part.FunctionCall != nil {
choice.FinishReason = constant.FinishReasonToolCalls
if call := getResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call)

View File

@@ -6,4 +6,4 @@ var ModelList = []string{
"m3e-small",
}
var ChannelName = "mokaai"
var ChannelName = "mokaai"

View File

@@ -89,17 +89,16 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayFormat {
case types.RelayFormatOpenAI:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
case types.RelayFormatClaude:
if info.IsStream {
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
} else {
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
default:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
}
return
}
func (a *Adaptor) GetModelList() []string {

View File

@@ -68,9 +68,7 @@ func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*O
StreamOptions: request.StreamOptions,
Suffix: request.Suffix,
}
if think, ok := request.Extra["think"]; ok {
ollamaRequest.Think = think
}
ollamaRequest.Think = request.Think
return ollamaRequest, nil
}

View File

@@ -538,7 +538,13 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
// 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
request.Reasoning.Effort = effort
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort
}
request.Model = originModel
}
return request, nil

View File

@@ -2,6 +2,7 @@ package openai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
@@ -280,11 +281,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) {
defer service.CloseResponseBodyGracefully(resp)
// count tokens by audio file duration
audioTokens, err := countAudioTokens(c)
if err != nil {
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
@@ -292,6 +288,26 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
var responseData struct {
Usage *dto.Usage `json:"usage"`
}
if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil {
if responseData.Usage.TotalTokens > 0 {
usage := responseData.Usage
if usage.PromptTokens == 0 {
usage.PromptTokens = usage.InputTokens
}
if usage.CompletionTokens == 0 {
usage.CompletionTokens = usage.OutputTokens
}
return nil, usage
}
}
audioTokens, err := countAudioTokens(c)
if err != nil {
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
}
usage := &dto.Usage{}
usage.PromptTokens = audioTokens
usage.CompletionTokens = 0

View File

@@ -46,9 +46,17 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
}
}
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
return &usage, nil
}
// 解析 Tools 用量
for _, tool := range responsesResponse.Tools {
info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++
buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])]
if !ok || buildToolinfo == nil {
logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"]))
continue
}
buildToolinfo.CallCount++
}
return &usage, nil
}
@@ -72,10 +80,16 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response.Usage != nil {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
@@ -92,6 +106,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
}
}
} else {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
}
return true
})
@@ -107,10 +123,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
usage.PromptTokens = usage.CompletionTokens
} else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.PromptTokens = info.PromptTokens
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return usage, nil
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -37,6 +36,7 @@ type requestPayload struct {
Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed"`
AspectRatio string `json:"aspect_ratio"`
Frames int `json:"frames,omitempty"`
}
type responsePayload struct {
@@ -74,7 +74,7 @@ type TaskAdaptor struct {
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
@@ -87,40 +87,25 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
action := constant.TaskActionGenerate
info.Action = action
req := relaycommon.TaskSubmitReq{}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Prompt) == "" {
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
return
}
// Store into context for later usage
c.Set("task_request", req)
return nil
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return a.signRequest(req, a.accessKey, a.secretKey)
}
// BuildRequestBody converts request into Jimeng specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
@@ -139,12 +124,12 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
@@ -327,18 +312,23 @@ func hmacSHA256(key []byte, data []byte) []byte {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
ReqKey: "jimeng_vgfm_i2v_l20",
Prompt: req.Prompt,
AspectRatio: "16:9", // Default aspect ratio
Seed: -1, // Default to random
ReqKey: req.Model,
Prompt: req.Prompt,
}
switch req.Duration {
case 10:
r.Frames = 241 // 24*10+1 = 241
default:
r.Frames = 121 // 24*5+1 = 121
}
// Handle one-of image_urls or binary_data_base64
if req.Image != "" {
if strings.HasPrefix(req.Image, "http") {
r.ImageUrls = []string{req.Image}
if req.HasImage() {
if strings.HasPrefix(req.Images[0], "http") {
r.ImageUrls = req.Images
} else {
r.BinaryDataBase64 = []string{req.Image}
r.BinaryDataBase64 = req.Images
}
}
metadata := req.Metadata
@@ -350,6 +340,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
// 即梦视频3.0 ReqKey转换
// https://www.volcengine.com/docs/85621/1792707
if strings.Contains(r.ReqKey, "jimeng_v30") {
if len(r.ImageUrls) > 1 {
// 多张图片:首尾帧生成
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
} else if len(r.ImageUrls) == 1 {
// 单张图片:图生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
} else {
// 无图片:文生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
}
}
return &r, nil
}

View File

@@ -4,18 +4,18 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/samber/lo"
"io"
"net/http"
"one-api/model"
"strings"
"time"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -27,25 +27,46 @@ import (
// Request / Response structures
// ============================
type SubmitReq struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
type TrajectoryPoint struct {
X int `json:"x"`
Y int `json:"y"`
}
type DynamicMask struct {
Mask string `json:"mask,omitempty"`
Trajectories []TrajectoryPoint `json:"trajectories,omitempty"`
}
type CameraConfig struct {
Horizontal float64 `json:"horizontal,omitempty"`
Vertical float64 `json:"vertical,omitempty"`
Pan float64 `json:"pan,omitempty"`
Tilt float64 `json:"tilt,omitempty"`
Roll float64 `json:"roll,omitempty"`
Zoom float64 `json:"zoom,omitempty"`
}
type CameraControl struct {
Type string `json:"type,omitempty"`
Config *CameraConfig `json:"config,omitempty"`
}
type requestPayload struct {
Prompt string `json:"prompt,omitempty"`
Image string `json:"image,omitempty"`
Mode string `json:"mode,omitempty"`
Duration string `json:"duration,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ModelName string `json:"model_name,omitempty"`
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
CfgScale float64 `json:"cfg_scale,omitempty"`
Prompt string `json:"prompt,omitempty"`
Image string `json:"image,omitempty"`
ImageTail string `json:"image_tail,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Mode string `json:"mode,omitempty"`
Duration string `json:"duration,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ModelName string `json:"model_name,omitempty"`
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
CfgScale float64 `json:"cfg_scale,omitempty"`
StaticMask string `json:"static_mask,omitempty"`
DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"`
CameraControl *CameraControl `json:"camera_control,omitempty"`
CallbackUrl string `json:"callback_url,omitempty"`
ExternalTaskId string `json:"external_task_id,omitempty"`
}
type responsePayload struct {
@@ -79,7 +100,7 @@ type TaskAdaptor struct {
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey
@@ -88,34 +109,19 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
action := constant.TaskActionGenerate
info.Action = action
var req SubmitReq
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Prompt) == "" {
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
return
}
// Store into context for later usage
c.Set("task_request", req)
return nil
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Use the standard validation method for TaskSubmitReq
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
return fmt.Sprintf("%s%s", a.baseURL, path), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
token, err := a.createJWTToken()
if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err)
@@ -129,17 +135,20 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
}
// BuildRequestBody converts request into Kling specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(SubmitReq)
req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
return nil, err
}
if body.Image == "" && body.ImageTail == "" {
c.Set("action", constant.TaskActionTextGenerate)
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
@@ -148,7 +157,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
if action := c.GetString("action"); action != "" {
info.Action = action
}
@@ -156,7 +165,7 @@ func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo,
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
@@ -220,16 +229,21 @@ func (a *TaskAdaptor) GetChannelName() string {
// helpers
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
Prompt: req.Prompt,
Image: req.Image,
Mode: defaultString(req.Mode, "std"),
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
AspectRatio: a.getAspectRatio(req.Size),
ModelName: req.Model,
Model: req.Model, // Keep consistent with model_name, double writing improves compatibility
CfgScale: 0.5,
Prompt: req.Prompt,
Image: req.Image,
Mode: defaultString(req.Mode, "std"),
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
AspectRatio: a.getAspectRatio(req.Size),
ModelName: req.Model,
Model: req.Model, // Keep consistent with model_name, double writing improves compatibility
CfgScale: 0.5,
StaticMask: "",
DynamicMasks: []DynamicMask{},
CameraControl: nil,
CallbackUrl: "",
ExternalTaskId: "",
}
if r.ModelName == "" {
r.ModelName = "kling-v1"

View File

@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -16,6 +15,8 @@ import (
"one-api/service"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type TaskAdaptor struct {
@@ -26,11 +27,11 @@ func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
return nil, fmt.Errorf("not implement") // todo implement this method if needed
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
}
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
action := strings.ToUpper(c.Param("action"))
var sunoRequest *dto.SunoSubmitReq
@@ -58,20 +59,20 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
return nil
}
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := info.ChannelBaseUrl
fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action)
return fullRequestURL, nil
}
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
return nil
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
sunoRequest, ok := c.Get("task_request")
if !ok {
err := common.UnmarshalBodyReusable(c, &sunoRequest)
@@ -86,11 +87,11 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
return bytes.NewReader(data), nil
}
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)

View File

@@ -0,0 +1,355 @@
package vertex
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/model"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
vertexcore "one-api/relay/channel/vertex"
relaycommon "one-api/relay/common"
"one-api/service"
)
// ============================
// Request / Response structures
// ============================
type requestPayload struct {
Instances []map[string]any `json:"instances"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type submitResponse struct {
Name string `json:"name"`
}
type operationVideo struct {
MimeType string `json:"mimeType"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
Encoding string `json:"encoding"`
}
type operationResponse struct {
Name string `json:"name"`
Done bool `json:"done"`
Response struct {
Type string `json:"@type"`
RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
Videos []operationVideo `json:"videos"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
Encoding string `json:"encoding"`
Video string `json:"video"`
} `json:"response"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
// ============================
// Adaptor implementation
// ============================
type TaskAdaptor struct {
ChannelType int
apiKey string
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Use the standard validation method for TaskSubmitReq
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
adc := &vertexcore.Credentials{}
if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil {
return "", fmt.Errorf("failed to decode credentials: %w", err)
}
modelName := info.OriginModelName
if modelName == "" {
modelName = "veo-3.0-generate-001"
}
region := vertexcore.GetModelRegion(info.ApiVersion, modelName)
if strings.TrimSpace(region) == "" {
region = "global"
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning",
adc.ProjectID,
modelName,
), nil
}
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
region,
adc.ProjectID,
region,
modelName,
), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
adc := &vertexcore.Credentials{}
if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil {
return fmt.Errorf("failed to decode credentials: %w", err)
}
token, err := vertexcore.AcquireAccessToken(*adc, "")
if err != nil {
return fmt.Errorf("failed to acquire access token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("x-goog-user-project", adc.ProjectID)
return nil
}
// BuildRequestBody converts request into Vertex specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, ok := c.Get("task_request")
if !ok {
return nil, fmt.Errorf("request not found in context")
}
req := v.(relaycommon.TaskSubmitReq)
body := requestPayload{
Instances: []map[string]any{{"prompt": req.Prompt}},
Parameters: map[string]any{},
}
if req.Metadata != nil {
if v, ok := req.Metadata["storageUri"]; ok {
body.Parameters["storageUri"] = v
}
if v, ok := req.Metadata["sampleCount"]; ok {
body.Parameters["sampleCount"] = v
}
}
if _, ok := body.Parameters["sampleCount"]; !ok {
body.Parameters["sampleCount"] = 1
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
}
_ = resp.Body.Close()
var s submitResponse
if err := json.Unmarshal(responseBody, &s); err != nil {
return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
}
if strings.TrimSpace(s.Name) == "" {
return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
}
localID := encodeLocalTaskID(s.Name)
c.JSON(http.StatusOK, gin.H{"task_id": localID})
return localID, responseBody, nil
}
func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} }
func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
upstreamName, err := decodeLocalTaskID(taskID)
if err != nil {
return nil, fmt.Errorf("decode task_id failed: %w", err)
}
region := extractRegionFromOperationName(upstreamName)
if region == "" {
region = "us-central1"
}
project := extractProjectFromOperationName(upstreamName)
modelName := extractModelFromOperationName(upstreamName)
if project == "" || modelName == "" {
return nil, fmt.Errorf("cannot extract project/model from operation name")
}
var url string
if region == "global" {
url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName)
} else {
url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName)
}
payload := map[string]string{"operationName": upstreamName}
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
adc := &vertexcore.Credentials{}
if err := json.Unmarshal([]byte(key), adc); err != nil {
return nil, fmt.Errorf("failed to decode credentials: %w", err)
}
token, err := vertexcore.AcquireAccessToken(*adc, "")
if err != nil {
return nil, fmt.Errorf("failed to acquire access token: %w", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("x-goog-user-project", adc.ProjectID)
return service.GetHttpClient().Do(req)
}
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
var op operationResponse
if err := json.Unmarshal(respBody, &op); err != nil {
return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
}
ti := &relaycommon.TaskInfo{}
if op.Error.Message != "" {
ti.Status = model.TaskStatusFailure
ti.Reason = op.Error.Message
ti.Progress = "100%"
return ti, nil
}
if !op.Done {
ti.Status = model.TaskStatusInProgress
ti.Progress = "50%"
return ti, nil
}
ti.Status = model.TaskStatusSuccess
ti.Progress = "100%"
if len(op.Response.Videos) > 0 {
v0 := op.Response.Videos[0]
if v0.BytesBase64Encoded != "" {
mime := strings.TrimSpace(v0.MimeType)
if mime == "" {
enc := strings.TrimSpace(v0.Encoding)
if enc == "" {
enc = "mp4"
}
if strings.Contains(enc, "/") {
mime = enc
} else {
mime = "video/" + enc
}
}
ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded
return ti, nil
}
}
if op.Response.BytesBase64Encoded != "" {
enc := strings.TrimSpace(op.Response.Encoding)
if enc == "" {
enc = "mp4"
}
mime := enc
if !strings.Contains(enc, "/") {
mime = "video/" + enc
}
ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded
return ti, nil
}
if op.Response.Video != "" { // some variants use `video` as base64
enc := strings.TrimSpace(op.Response.Encoding)
if enc == "" {
enc = "mp4"
}
mime := enc
if !strings.Contains(enc, "/") {
mime = "video/" + enc
}
ti.Url = "data:" + mime + ";base64," + op.Response.Video
return ti, nil
}
return ti, nil
}
// ============================
// helpers
// ============================
func encodeLocalTaskID(name string) string {
return base64.RawURLEncoding.EncodeToString([]byte(name))
}
func decodeLocalTaskID(local string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(local)
if err != nil {
return "", err
}
return string(b), nil
}
var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`)
func extractRegionFromOperationName(name string) string {
m := regionRe.FindStringSubmatch(name)
if len(m) == 2 {
return m[1]
}
return ""
}
var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
func extractModelFromOperationName(name string) string {
m := modelRe.FindStringSubmatch(name)
if len(m) == 2 {
return m[1]
}
idx := strings.Index(name, "models/")
if idx >= 0 {
s := name[idx+len("models/"):]
if p := strings.Index(s, "/operations/"); p > 0 {
return s[:p]
}
}
return ""
}
var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`)
func extractProjectFromOperationName(name string) string {
m := projectRe.FindStringSubmatch(name)
if len(m) == 2 {
return m[1]
}
return ""
}

View File

@@ -23,16 +23,6 @@ import (
// Request / Response structures
// ============================
type SubmitReq struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type requestPayload struct {
Model string `json:"model"`
Images []string `json:"images"`
@@ -84,37 +74,22 @@ type TaskAdaptor struct {
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
}
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError {
var req SubmitReq
if err := c.ShouldBindJSON(&req); err != nil {
return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest)
}
if req.Prompt == "" {
return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "missing_prompt", http.StatusBadRequest)
}
if req.Image != "" {
info.Action = constant.TaskActionGenerate
} else {
info.Action = constant.TaskActionTextGenerate
}
c.Set("task_request", req)
return nil
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
// Use the unified validation method for TaskSubmitReq with image-based action determination
return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) {
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(SubmitReq)
req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
@@ -132,7 +107,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayI
return bytes.NewReader(data), nil
}
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
var path string
switch info.Action {
case constant.TaskActionGenerate:
@@ -143,21 +118,21 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string,
return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil
}
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Token "+info.ApiKey)
return nil
}
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
if action := c.GetString("action"); action != "" {
info.Action = action
}
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
@@ -211,7 +186,7 @@ func (a *TaskAdaptor) GetChannelName() string {
// helpers
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
var images []string
if req.Image != "" {
images = []string{req.Image}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
@@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
adc := &Credentials{}
if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
return "", fmt.Errorf("failed to decode credentials file: %w", err)
}
func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) {
region := GetModelRegion(info.ApiVersion, info.OriginModelName)
a.AccountCredentials = *adc
if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
adc := &Credentials{}
if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil {
return "", fmt.Errorf("failed to decode credentials file: %w", err)
}
a.AccountCredentials = *adc
if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
adc.ProjectID,
region,
), nil
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
modelName,
suffix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
region,
modelName,
suffix,
info.ApiKey,
), nil
}
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
@@ -111,24 +160,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
suffix = "predict"
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
info.UpstreamModelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
info.UpstreamModelName,
suffix,
), nil
}
return a.getRequestUrl(info, info.UpstreamModelName, suffix)
} else if a.RequestMode == RequestModeClaude {
if info.IsStream {
suffix = "streamRawPredict?alt=sse"
@@ -139,41 +171,25 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
model = v
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
adc.ProjectID,
model,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
model,
suffix,
), nil
}
return a.getRequestUrl(info, model, suffix)
} else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
adc.ProjectID,
region,
), nil
return a.getRequestUrl(info, "", "")
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
accessToken, err := getAccessToken(a, info)
if err != nil {
return err
if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
accessToken, err := getAccessToken(a, info)
if err != nil {
return err
}
req.Set("Authorization", "Bearer "+accessToken)
}
if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
req.Set("Authorization", "Bearer "+accessToken)
return nil
}
@@ -279,31 +295,31 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.IsStream {
switch a.RequestMode {
case RequestModeClaude:
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
case RequestModeGemini:
if info.RelayMode == constant.RelayModeGemini {
usage, err = gemini.GeminiTextGenerationStreamHandler(c, info, resp)
return gemini.GeminiTextGenerationStreamHandler(c, info, resp)
} else {
usage, err = gemini.GeminiChatStreamHandler(c, info, resp)
return gemini.GeminiChatStreamHandler(c, info, resp)
}
case RequestModeLlama:
usage, err = openai.OaiStreamHandler(c, info, resp)
return openai.OaiStreamHandler(c, info, resp)
}
} else {
switch a.RequestMode {
case RequestModeClaude:
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
case RequestModeGemini:
if info.RelayMode == constant.RelayModeGemini {
usage, err = gemini.GeminiTextGenerationHandler(c, info, resp)
return gemini.GeminiTextGenerationHandler(c, info, resp)
} else {
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
return gemini.GeminiImageHandler(c, info, resp)
}
usage, err = gemini.GeminiChatHandler(c, info, resp)
return gemini.GeminiChatHandler(c, info, resp)
}
case RequestModeLlama:
usage, err = openai.OpenaiHandler(c, info, resp)
return openai.OpenaiHandler(c, info, resp)
}
}
return

View File

@@ -12,7 +12,10 @@ func GetModelRegion(other string, localModelName string) string {
if m[localModelName] != nil {
return m[localModelName].(string)
} else {
return m["default"].(string)
if v, ok := m["default"]; ok {
return v.(string)
}
return "global"
}
}
return other

View File

@@ -6,14 +6,15 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"github.com/bytedance/gopkg/cache/asynccache"
"github.com/golang-jwt/jwt"
"net/http"
"net/url"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
"github.com/bytedance/gopkg/cache/asynccache"
"github.com/golang-jwt/jwt"
"fmt"
"time"
)
@@ -137,3 +138,45 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
return "", fmt.Errorf("failed to get access token: %v", result)
}
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
}
func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
var client *http.Client
var err error
if proxy != "" {
client, err = service.NewProxyHttpClient(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
} else {
client = service.GetHttpClient()
}
resp, err := client.PostForm(authURL, data)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if accessToken, ok := result["access_token"].(string); ok {
return accessToken, nil
}
return "", fmt.Errorf("failed to get access token: %v", result)
}

View File

@@ -2,6 +2,7 @@ package volcengine
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
// 适配 方舟deepseek混合模型 的 thinking 后缀
if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
request.THINKING = json.RawMessage(`{"type": "enabled"}`)
}
return request, nil
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
@@ -23,10 +24,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")
return nil, nil
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
return req, nil
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
@@ -43,12 +42,16 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseUrl := fmt.Sprintf("%s/api/paas/v4", info.ChannelBaseUrl)
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
return fmt.Sprintf("%s/embeddings", baseUrl), nil
switch info.RelayFormat {
case types.RelayFormatClaude:
return fmt.Sprintf("%s/api/anthropic/v1/messages", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/chat/completions", baseUrl), nil
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/paas/v4/embeddings", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/api/paas/v4/chat/completions", info.ChannelBaseUrl), nil
}
}
}
@@ -86,12 +89,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
switch info.RelayFormat {
case types.RelayFormatClaude:
if info.IsStream {
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
} else {
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
default:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
}
return
}
func (a *Adaptor) GetModelList() []string {

View File

@@ -21,13 +21,18 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
info.InitChannelMeta(c)
textRequest, ok := info.Request.(*dto.ClaudeRequest)
claudeReq, ok := info.Request.(*dto.ClaudeRequest)
if !ok {
common.FatalLog(fmt.Sprintf("invalid request type, expected *dto.ClaudeRequest, got %T", info.Request))
return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.ClaudeRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
err := helper.ModelMappedHelper(c, info, textRequest)
request, err := common.DeepCopy(claudeReq)
if err != nil {
return types.NewError(fmt.Errorf("failed to copy request to ClaudeRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
@@ -38,30 +43,30 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
adaptor.Init(info)
if textRequest.MaxTokens == 0 {
textRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
if request.MaxTokens == 0 {
request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
}
if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
if textRequest.Thinking == nil {
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
// 因为BudgetTokens 必须大于1024
if textRequest.MaxTokens < 1280 {
textRequest.MaxTokens = 1280
if request.MaxTokens < 1280 {
request.MaxTokens = 1280
}
// BudgetTokens 为 max_tokens 的 80%
textRequest.Thinking = &dto.Thinking{
request.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
BudgetTokens: common.GetPointer[int](int(float64(request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
textRequest.TopP = 0
textRequest.Temperature = common.GetPointer[float64](1.0)
request.TopP = 0
request.Temperature = common.GetPointer[float64](1.0)
}
textRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
info.UpstreamModelName = textRequest.Model
request.Model = strings.TrimSuffix(request.Model, "-thinking")
info.UpstreamModelName = request.Model
}
var requestBody io.Reader
@@ -72,7 +77,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
requestBody = bytes.NewBuffer(body)
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, textRequest)
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
@@ -106,7 +111,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false)
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"regexp"
"strconv"
"strings"
)
@@ -151,7 +153,9 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri
}
func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) {
value := gjson.Get(jsonStr, condition.Path)
// 处理负数索引
path := processNegativeIndex(jsonStr, condition.Path)
value := gjson.Get(jsonStr, path)
if !value.Exists() {
if condition.PassMissingKey {
return true, nil
@@ -177,6 +181,37 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e
return result, nil
}
func processNegativeIndex(jsonStr string, path string) string {
re := regexp.MustCompile(`\.(-\d+)`)
matches := re.FindAllStringSubmatch(path, -1)
if len(matches) == 0 {
return path
}
result := path
for _, match := range matches {
negIndex := match[1]
index, _ := strconv.Atoi(negIndex)
arrayPath := strings.Split(path, negIndex)[0]
if strings.HasSuffix(arrayPath, ".") {
arrayPath = arrayPath[:len(arrayPath)-1]
}
array := gjson.Get(jsonStr, arrayPath)
if array.IsArray() {
length := len(array.Array())
actualIndex := length + index
if actualIndex >= 0 && actualIndex < length {
result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1)
}
}
}
return result
}
// compareGjsonValues 直接比较两个gjson.Result支持所有比较模式
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
switch mode {
@@ -274,21 +309,25 @@ func applyOperations(jsonStr string, operations []ParamOperation) (string, error
if !ok {
continue // 条件不满足,跳过当前操作
}
// 处理路径中的负数索引
opPath := processNegativeIndex(result, op.Path)
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
switch op.Mode {
case "delete":
result, err = sjson.Delete(result, op.Path)
result, err = sjson.Delete(result, opPath)
case "set":
if op.KeepOrigin && gjson.Get(result, op.Path).Exists() {
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
continue
}
result, err = sjson.Set(result, op.Path, op.Value)
result, err = sjson.Set(result, opPath, op.Value)
case "move":
result, err = moveValue(result, op.From, op.To)
result, err = moveValue(result, opFrom, opTo)
case "prepend":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, true)
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
case "append":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, false)
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
default:
return "", fmt.Errorf("unknown operation: %s", op.Mode)
}

View File

@@ -63,6 +63,7 @@ type ChannelMeta struct {
Organization string
ChannelCreateTime int64
ParamOverride map[string]interface{}
HeadersOverride map[string]interface{}
ChannelSetting dto.ChannelSettings
ChannelOtherSettings dto.ChannelOtherSettings
UpstreamModelName string
@@ -115,11 +116,13 @@ type RelayInfo struct {
*RerankerInfo
*ResponsesUsageInfo
*ChannelMeta
*TaskRelayInfo
}
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
apiType, _ := common.ChannelType2APIType(channelType)
channelMeta := &ChannelMeta{
ChannelType: channelType,
@@ -133,6 +136,7 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
Organization: c.GetString("channel_organization"),
ChannelCreateTime: c.GetInt64("channel_create_time"),
ParamOverride: paramOverride,
HeadersOverride: headerOverride,
UpstreamModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel),
IsModelMapped: false,
SupportStreamOptions: false,
@@ -158,7 +162,14 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
if streamSupportedChannels[channelMeta.ChannelType] {
channelMeta.SupportStreamOptions = true
}
info.ChannelMeta = channelMeta
// reset some fields based on channel meta
// 重置某些字段,例如模型名称等
if info.Request != nil {
info.Request.SetModelName(info.OriginModelName)
}
}
func (info *RelayInfo) ToString() string {
@@ -303,7 +314,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
BuiltInTools: make(map[string]*BuildInToolInfo),
}
if len(request.Tools) > 0 {
for _, tool := range request.Tools {
for _, tool := range request.GetToolsMap() {
toolType := common.Interface2String(tool["type"])
info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{
ToolName: toolType,
@@ -390,6 +401,10 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
},
}
if info.RelayMode == relayconstant.RelayModeUnknown {
info.RelayMode = c.GetInt("relay_mode")
}
if strings.HasPrefix(c.Request.URL.Path, "/pg") {
info.IsPlayground = true
info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg")
@@ -455,34 +470,31 @@ func (info *RelayInfo) HasSendResponse() bool {
}
type TaskRelayInfo struct {
*RelayInfo
Action string
OriginTaskID string
ConsumeQuota bool
}
func GenTaskRelayInfo(c *gin.Context) (*TaskRelayInfo, error) {
relayInfo, err := GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
return nil, err
}
info := &TaskRelayInfo{
RelayInfo: relayInfo,
}
return info, nil
}
type TaskSubmitReq struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
Images []string `json:"images,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (t TaskSubmitReq) GetPrompt() string {
return t.Prompt
}
func (t TaskSubmitReq) HasImage() bool {
return len(t.Images) > 0
}
type TaskInfo struct {
Code int `json:"code"`
TaskID string `json:"task_id"`

View File

@@ -2,14 +2,23 @@ package common
import (
"fmt"
"github.com/gin-gonic/gin"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"strings"
"github.com/gin-gonic/gin"
)
type HasPrompt interface {
GetPrompt() string
}
type HasImage interface {
HasImage() bool
}
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
@@ -32,3 +41,72 @@ func GetAPIVersion(c *gin.Context) string {
}
return apiVersion
}
func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError {
return &dto.TaskError{
Code: code,
Message: err.Error(),
StatusCode: statusCode,
LocalError: localError,
Error: err,
}
}
func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj interface{}) {
info.Action = action
c.Set("task_request", requestObj)
}
func validatePrompt(prompt string) *dto.TaskError {
if strings.TrimSpace(prompt) == "" {
return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true)
}
return nil
}
func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
var req TaskSubmitReq
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
if taskErr := validatePrompt(req.Prompt); taskErr != nil {
return taskErr
}
if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
// 兼容单图上传
req.Images = []string{req.Image}
}
storeTaskRequest(c, info, action, req)
return nil
}
func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
hasPrompt, ok := requestObj.(HasPrompt)
if !ok {
return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
}
if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
return taskErr
}
action := constant.TaskActionTextGenerate
if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
action = constant.TaskActionGenerate
}
storeTaskRequest(c, info, action, requestObj)
return nil
}
func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
var req TaskSubmitReq
if err := c.ShouldBindJSON(&req); err != nil {
return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
}
return ValidateTaskRequestWithImage(c, info, req)
}

View File

@@ -25,38 +25,40 @@ import (
)
func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
textRequest, ok := info.Request.(*dto.GeneralOpenAIRequest)
textReq, ok := info.Request.(*dto.GeneralOpenAIRequest)
if !ok {
//return types.NewErrorWithStatusCode(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
common.FatalLog("invalid request type, expected dto.GeneralOpenAIRequest, got %T", info.Request)
return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.GeneralOpenAIRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
if textRequest.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
request, err := common.DeepCopy(textReq)
if err != nil {
return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
err := helper.ModelMappedHelper(c, info, textRequest)
if request.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", request.WebSearchOptions.SearchContextSize)
}
err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
includeUsage := true
// 判断用户是否需要返回使用情况
if textRequest.StreamOptions != nil {
includeUsage = textRequest.StreamOptions.IncludeUsage
if request.StreamOptions != nil {
includeUsage = request.StreamOptions.IncludeUsage
}
// 如果不支持StreamOptions将StreamOptions设置为nil
if !info.SupportStreamOptions || !textRequest.Stream {
textRequest.StreamOptions = nil
if !info.SupportStreamOptions || !request.Stream {
request.StreamOptions = nil
} else {
// 如果支持StreamOptions且请求中没有设置StreamOptions根据配置文件设置StreamOptions
if constant.ForceStreamOption {
textRequest.StreamOptions = &dto.StreamOptions{
request.StreamOptions = &dto.StreamOptions{
IncludeUsage: true,
}
}
@@ -81,7 +83,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
}
requestBody = bytes.NewBuffer(body)
} else {
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, textRequest)
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
@@ -128,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
jsonData, err := common.Marshal(convertedRequest)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
@@ -156,7 +158,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
newApiErr := service.RelayErrorHandler(httpResp, false)
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
return newApiErr
@@ -193,6 +195,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
@@ -202,6 +206,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio
// Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@@ -209,12 +214,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio)
dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice)
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio)
@@ -282,6 +289,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
baseTokens = baseTokens.Sub(dCacheTokens)
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
var dCachedCreationTokensWithRatio decimal.Decimal
if !dCachedCreationTokens.IsZero() {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
}
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
@@ -300,7 +312,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
promptQuota := baseTokens.Add(cachedTokensWithRatio).
Add(imageTokensWithRatio).
Add(dCachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
@@ -382,6 +396,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["image_ratio"] = imageRatio
other["image_output"] = imageTokens
}
if cachedCreationTokens != 0 {
other["cache_creation_tokens"] = cachedCreationTokens
other["cache_creation_ratio"] = cachedCreationRatio
}
if !dWebSearchQuota.IsZero() {
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {

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