Compare commits

...

147 Commits

Author SHA1 Message Date
CaIon
29ec328f46 fix: playground group 2025-08-08 11:59:04 +08:00
CaIon
b843bb8286 feat: add support for gpt-5 models and adjust temperature settings
- Updated the model list to include various gpt-5 variants.
- Enhanced the ConvertOpenAIRequest function to handle gpt-5 model temperature settings based on specific model prefixes.
- Adjusted default cache and model ratios for new gpt-5 models.
2025-08-08 10:43:07 +08:00
Calcium-Ion
77975529fe Merge pull request #1525 from HynoR/chore/gpt5
feat: sync gpt-5 model ratio and support new reasoning effort
2025-08-08 10:24:13 +08:00
Calcium-Ion
9de65184ab Merge pull request #1523 from RedwindA/refactor/improve-modelName-wildcard
fix: 修复通配符处理对判断可用渠道的破坏
2025-08-08 10:22:47 +08:00
HynoR
4912b1e632 feat: sync gpt-5 model ratio and support new reasoning effort 2025-08-08 09:11:28 +08:00
t0ng7u
d0fb54fbfe feat(web): add model prefill group quick-add buttons to channel models selector
- Added support to fetch and render “model prefill groups” in `EditChannelModal.jsx`
- Users can now click a group button to instantly merge that group’s models into the models Select
- Mirrors the prefill-group UX used for tags/endpoints in `EditModelModal.jsx`

Details
- UI/UX:
  - Renders one button per model group inside the models field’s extra actions
  - Clicking a button merges its items into the selected models (trimmed, deduplicated), updating immediately
  - Non-destructive and works alongside existing actions (fill related/all models, fetch upstream, clear, copy)
- API:
  - GET `/api/prefill_group?type=model`
  - Handles `items` as either an array or a JSON string array for robustness
  - If request fails or returns no groups, buttons are simply not shown
- i18n:
  - Reuses existing i18n; group names come from backend and are displayed as-is
- Performance:
  - Simple set merge; negligible overhead
- Backward compatibility:
  - No changes required on the backend or elsewhere; feature is additive
- Testing (manual):
  1) Open channel modal (new or edit) and navigate to the Models section
  2) Confirm model group buttons render when groups are configured
  3) Click a group button → models Select updates with merged models (no duplicates)
  4) Verify other actions (fill related/all, fetch upstream, clear, copy) still work
  5) Close/reopen modal → state resets as expected

Implementation
- `web/src/components/table/channels/modals/EditChannelModal.jsx`
  - Added `modelGroups` state and `fetchModelGroups()` (GET `/api/prefill_group?type=model`)
  - Invoked `fetchModelGroups()` when the modal opens
  - Rendered group buttons in the models Select `extraText`, merging group items into current selection

Chore
- Verified no new linter errors were introduced.
2025-08-08 04:48:18 +08:00
t0ng7u
346b869d60 💄 ui: adjust column layout in pricing table
- Move model price column to fixed right position
- Convert endpoint types column from fixed to regular column
- Reorder columns: endpoint types now appears before ratio column
- Improve table layout and user experience for pricing data viewing

Changes made to web/src/components/table/model-pricing/view/table/PricingTableColumns.js:
* Removed `fixed: 'right'` from endpointColumn
* Added `fixed: 'right'` to priceColumn
* Updated column order in the columns array
2025-08-08 04:41:46 +08:00
同語
ac158e227e 🤓feat: the model management module
Merge pull request #1452 from QuantumNous/refactor/model-pricing
2025-08-08 04:23:12 +08:00
t0ng7u
d96f846648 🐛 fix(model, web): robust JSON handling; remove datatypes dep; stabilize JSONEditor manual mode
- Why:
  - Eliminate `gorm.io/datatypes` for a single field and fix scan errors when drivers return JSON as string.
  - Prevent JSONEditor manual mode from locking on invalid JSON and from appending stray characters after “Fill Template”.

- What:
  - Backend (`model/prefill_group.go`):
    - Replaced `datatypes.JSON` with `JSONValue` (based on `json.RawMessage`) for `PrefillGroup.Items`.
    - Implemented `sql.Scanner` and `driver.Valuer` to accept both `[]byte` and `string`.
    - Implemented `MarshalJSON`/`UnmarshalJSON` to preserve raw JSON in API without base64.
    - Converted comments to Chinese.
  - Frontend (`web/src/components/common/ui/JSONEditor.js`):
    - Added `manualText` buffer for manual mode to avoid input being overridden by external value.
    - Only propagate `onChange` when manual text is valid JSON; otherwise show error but do not block typing.
    - Safe manual-mode rendering: derive rows from `manualText` and avoid calling `split` on non-strings.
    - Improved mode toggle: populate `manualText` from visual data; validate before switching back to visual.
    - Fixed “Fill Template” to sync `manualText`, `jsonData`, and `onChange` to avoid stray trailing characters.

- Impact:
  - Resolves: “unsupported Scan, storing driver.Value type string into type *json.RawMessage”.
  - Resolves: `value.split is not a function` in manual mode.
  - Resolves: extra `s` appended after inserting template.
  - API shape and DB column type remain the same (`gorm:"type:json"`); no `go.mod` changes.
  - Lints pass for modified files.

Files changed:
- model/prefill_group.go
- web/src/components/common/ui/JSONEditor.js
2025-08-08 04:21:50 +08:00
t0ng7u
473f3b6f3e ♻️ refactor(model): replace gorm.io/datatypes with JSONValue for PrefillGroup.Items; fix JSON scan across drivers
- Why:
  - Avoid introducing `gorm.io/datatypes` for a single field.
  - Align with existing pattern (`ChannelInfo`, `Properties`) using `Scanner`/`Valuer`.
  - Fix runtime error when drivers return JSON as string.

- What:
  - Introduced `JSONValue` (based on `json.RawMessage`) implementing `sql.Scanner` and `driver.Valuer`, with `MarshalJSON`/`UnmarshalJSON` to preserve raw JSON in API.
  - Updated `PrefillGroup.Items` to use `JSONValue` with `gorm:"type:json"`.
  - Localized comments in `model/prefill_group.go` to Chinese.

- Impact:
  - Resolves “unsupported Scan, storing driver.Value type string into type *json.RawMessage”.
  - Works with MySQL/Postgres/SQLite whether JSON is returned as `[]byte` or `string`.
  - API and DB schema remain unchanged; no `go.mod` changes; lints pass.

Files changed:
- model/prefill_group.go
2025-08-08 04:09:53 +08:00
t0ng7u
7f1a471751 ♻️ refactor(model): replace gorm.io/datatypes with json.RawMessage for PrefillGroup.Items
- Why: Avoid adding `gorm.io/datatypes` for a single field; the rest of the codebase does not use it, and using the standard library keeps dependencies lean.
- What:
  - Switched `PrefillGroup.Items` from `datatypes.JSON` to `json.RawMessage`.
  - Updated imports in `model/prefill_group.go` to use `encoding/json` and removed the unused `gorm.io/datatypes`.
  - Preserved `gorm:"type:json"` so DB column behavior remains the same.
- Impact:
  - API response/request shape for `items` remains unchanged (still JSON).
  - DB schema behavior is unchanged; GORM migration continues to handle the field as JSON.
  - No other references to `datatypes` exist; no `go.mod` changes needed.
  - Lints pass for the modified file.

Files changed:
- model/prefill_group.go

No breaking changes.
2025-08-08 03:52:07 +08:00
t0ng7u
bbac342f3a 🤔 revert: revert go.sum & go.mod version 2025-08-08 03:35:31 +08:00
t0ng7u
4b3702987f 🤔 revert: revert go.sum & go.mod version 2025-08-08 03:22:25 +08:00
t0ng7u
6341847203 🎨 refactor(ui): merge “Content Configuration” into “Basic Information” card in EditPrefillGroupModal
- Move `items` field (`JSONEditor` for endpoint type, `Form.TagInput` otherwise) into the first “Basic Information” card
- Remove the second “Content Configuration” card and its header; consolidate to a single-card layout
- Preserve form initialization, validation, and submit logic; API payload structure remains unchanged
- Improves clarity and reduces visual clutter without altering behavior
- Lint passes

Affected file:
- `web/src/components/table/models/modals/EditPrefillGroupModal.jsx`

No breaking changes.
2025-08-08 03:04:51 +08:00
t0ng7u
4e75a9b3b3 feat: Improve models UX and robustness: add JSONEditor extraFooter, fix endpoints rendering, and clean up deps
- Why
  - Needed to separate help text from action buttons in JSONEditor for better layout and UX.
  - Models table should robustly render both new object-based endpoint mappings and legacy arrays.
  - Columns should re-render when vendor map changes.
  - Minor import cleanups for consistency.

- What
  - JSONEditor.js
    - Added optional prop extraFooter to render content below the extraText divider.
    - Kept extraText rendered via Divider; extraFooter appears on the next line for clear separation.
  - EditModelModal.jsx
    - Moved endpoint group buttons from extraText into extraFooter to display under the helper text.
    - Kept merge-logic: group items are merged into current endpoints JSON with key override semantics.
    - Consolidated lucide-react imports into a single line.
  - ModelsColumnDefs.js
    - Made endpoint renderer resilient:
      - Supports object-based JSON (keys as endpoint types) and legacy array format.
      - Displays keys/items as tags and limits the number shown; uses stringToColor for visual consistency.
    - Consolidated Semi UI imports into a single line.
  - ModelsTable.jsx
    - Fixed columns memoization dependency to include vendorMap, ensuring re-render when vendor data changes.

- Notes
  - Backward-compatible: extraFooter is additive; existing JSONEditor usage remains unchanged.
  - No API changes to backend.
  - No linter errors introduced.

- Files touched
  - web/src/components/common/ui/JSONEditor.js
  - web/src/components/table/models/modals/EditModelModal.jsx
  - web/src/components/table/models/ModelsColumnDefs.js
  - web/src/components/table/models/ModelsTable.jsx

- Impact
  - Clearer UI for endpoint editing (buttons now below helper text).
  - Correct endpoints display for object-based mappings in models list.
  - More reliable reactivity when vendor data updates.
2025-08-08 02:59:45 +08:00
t0ng7u
26f44b8d4b Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-08 02:34:44 +08:00
t0ng7u
8fba0017c7 feat(pricing+endpoints+ui): wire custom endpoint mapping end‑to‑end and overhaul visual JSON editor
Backend (Go)
- Include custom endpoints in each model’s SupportedEndpointTypes by parsing Model.Endpoints (JSON) and appending keys alongside native endpoint types.
- Build a global supportedEndpointMap map[string]EndpointInfo{path, method} by:
  - Seeding with native defaults.
  - Overriding/adding from models.endpoints (accepts string path → default POST, or {path, method}).
- Expose supported_endpoint at the top level of /api/pricing (vendors-like), removing per-model duplication.
- Fix default path for EndpointTypeOpenAIResponse to /v1/responses.
- Keep concurrency/caching for pricing retrieval intact.

Frontend (React)
- Fetch supported_endpoint in useModelPricingData and propagate to PricingPage → ModelDetailSideSheet → ModelEndpoints.
- ModelEndpoints
  - Resolve path+method via endpointMap; replace {model} with actual model name.
  - Fix mobile visibility; always show path and HTTP method.
- JSONEditor
  - Wrap with Form.Slot to inherit form layout; simplify visual styles.
  - Use Tabs for “Visual” / “Manual” modes.
  - Unify editors: key-value editor now supports nested JSON:
    - “+” to convert a primitive into an object and add nested fields.
    - Add “Convert to value” for two‑way toggle back from object.
    - Stable key rename without reordering rows; new rows append at bottom.
    - Use Row/Col grid for clean alignment; region editor uses Form.Slot + grid.
- Editing flows
  - EditModelModal / EditPrefillGroupModal use JSONEditor (editorType='object') for endpoint mappings.
  - PrefillGroupManagement renders endpoint group items by JSON keys.

Data expectations / compatibility
- models.endpoints should be a JSON object mapping endpoint type → string path or {path, method}. Strings default to POST.
- No schema changes; existing TEXT field continues to store JSON.

QA
- /api/pricing now returns custom endpoint types and global supported_endpoint.
- UI shows both native and custom endpoints; paths/methods render on mobile; nested editing works and preserves order.
2025-08-08 02:34:15 +08:00
RedwindA
7f4056abc9 feat: optimize channel retrieval by respecting original model names 2025-08-07 21:58:15 +08:00
RedwindA
0257918571 feat: add default model ratio for gemini-2.5-flash-lite-preview-thinking model 2025-08-07 21:39:11 +08:00
RedwindA
1d4e746c4f feat: update FormatMatchingModelName to handle gemini-2.5-flash-lite model prefix 2025-08-07 21:37:08 +08:00
Calcium-Ion
677a02c632 Merge pull request #1517 from RedwindA/fix/gemini-fetch-models
Fix/gemini-fetch-models
2025-08-07 20:44:34 +08:00
Calcium-Ion
177b891905 Merge pull request #1518 from RedwindA/fix/disable-gemini-sse
Fix/disable-gemini-ping
2025-08-07 20:44:24 +08:00
CaIon
c4dcc6df9c feat: enhance Adaptor to support multiple relay modes in request handling 2025-08-07 19:30:42 +08:00
CaIon
7ddd314015 feat: implement ConvertClaudeRequest method in baidu_v2 Adaptor 2025-08-07 19:19:59 +08:00
Calcium-Ion
ba7325c884 Merge pull request #1522 from QuantumNous/support-deepseek-claude
feat: support deepseek claude format (convert)
2025-08-07 19:04:05 +08:00
Calcium-Ion
3c4b1ef127 Merge pull request #1521 from QuantumNous/support-qwen-claude
feat: support qwen claude format
2025-08-07 19:03:40 +08:00
CaIon
18c630e5e4 feat: support deepseek claude format (convert) 2025-08-07 19:01:49 +08:00
CaIon
0ea0a432bf feat: support qwen claude format 2025-08-07 18:32:31 +08:00
IcedTangerine
8a964efbed Merge pull request #1519 from feitianbubu/pr/fix-qwen3-thinking-test
feat: enable thinking mode on ali thinking model
2025-08-07 17:39:27 +08:00
CaIon
865bb7aad8 Revert "feat: update Usage struct to support dynamic token handling with ceil function #1503"
This reverts commit 71c39c9893.
2025-08-07 16:22:40 +08:00
CaIon
d9c1fb5244 feat: update MaxTokens handling 2025-08-07 16:15:59 +08:00
CaIon
71c39c9893 feat: update Usage struct to support dynamic token handling with ceil function #1503 2025-08-07 15:40:12 +08:00
feitianbubu
38067f1ddc feat: enable thinking mode on ali thinking model 2025-08-07 11:59:54 +08:00
t0ng7u
7cfeb6e87c Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-07 11:09:28 +08:00
t0ng7u
0a231a8acc 🎨 feat(models): add row styling for disabled models in ModelsTable
Add visual distinction for enabled/disabled models by applying different
background colors to table rows based on model status. This implementation
follows the same pattern used in ChannelsTable for consistent user experience.

Changes:
- Modified handleRow function in useModelsData.js to include row styling
- Disabled models (status !== 1) now display with gray background using
  --semi-color-disabled-border CSS variable
- Enabled models (status === 1) maintain normal background color
- Preserved existing row click selection functionality

This enhancement improves the visual feedback for users to quickly identify
which models are active vs inactive in the models management interface.
2025-08-07 10:54:05 +08:00
RedwindA
1cea7a0314 fix: 调整Disable Ping标志的设置位置 2025-08-07 06:18:22 +08:00
RedwindA
ed95a9f2b2 fix a typo in comment 2025-08-07 01:11:01 +08:00
RedwindA
76d71a032a fix: 修复 FetchUpstreamModels 函数中 AuthHeader 的使用,确保正确处理 多key聚合的情况 2025-08-07 01:01:45 +08:00
RedwindA
38bff1a0e0 refactor: 移除 GoogleOpenAI 兼容模型相关结构体,简化 FetchUpstreamModels 函数逻辑 2025-08-07 00:54:48 +08:00
Xyfacai
0c0caad827 refactor: 调整模型匹配 2025-08-06 20:09:22 +08:00
Xyfacai
4445e5891f fix: error code 显示问题 2025-08-06 19:40:26 +08:00
CaIon
f46cefbd39 fix: update budget calculation logic in relay-gemini to use clamping function 2025-08-06 16:25:48 +08:00
CaIon
feef022303 feat: enhance ThinkingAdaptor with effort-based budget clamping and extra body handling 2025-08-06 16:20:38 +08:00
CaIon
6a80c18189 feat: add reasoning support for Openrouter requests with "-thinking" suffix 2025-08-06 12:50:26 +08:00
Calcium-Ion
6616bb4048 Merge pull request #1508 from wzxjohn/feature/aws_new_apikey_support
feat: support aws bedrock apikey
2025-08-06 12:04:28 +08:00
Calcium-Ion
ac5f51c3d5 Merge pull request #1510 from RedwindA/fix/manual-price-edit-modelName-check
fix:修复添加模型倍率时的输入框锁定
2025-08-06 12:03:44 +08:00
Calcium-Ion
587888a688 Merge pull request #1511 from neotf/feat-05
feat: add support for claude-opus-4-1 model and update ratios
2025-08-06 12:03:33 +08:00
Calcium-Ion
7370b4fbcd Merge pull request #1509 from QuantumNous/responses-input-cache-token
fix: responses cache token 未计费
2025-08-06 11:22:14 +08:00
t0ng7u
94506bee99 feat(models): Revamp EditModelModal UI and UX
This commit significantly refactors the `EditModelModal` component to streamline the user interface and enhance usability, aligning it with the interaction patterns found elsewhere in the application.

- **Consolidated Layout:** Merged the "Vendor Information" and "Feature Configuration" sections into a single "Basic Information" card. This simplifies the form, reduces clutter, and makes all settings accessible in one view.

- **Improved Prefill Groups:** Replaced the separate `Select` dropdowns for tag and endpoint groups with a more intuitive button-based system within the `extraText` of the `TagInput` components.

- **Additive Button Logic:** The prefill group buttons now operate in an additive mode. Users can click multiple group buttons to incrementally add tags or endpoints, with duplicates being automatically handled.

- **Clear Functionality:** Added "Clear" buttons for both tags and endpoints, allowing users to easily reset the fields.

- **Code Cleanup:** Removed the unused `endpointOptions` constant and unnecessary icon imports (`Building`, `Settings`) to keep the codebase clean.
2025-08-06 03:29:45 +08:00
t0ng7u
7c814a5fd9 🚀 refactor: migrate vendor-count aggregation to model layer & align frontend logic
Summary
• Backend
  – Moved duplicate-name validation and total vendor-count aggregation from controllers (`controller/model_meta.go`, `controller/vendor_meta.go`, `controller/prefill_group.go`) to model layer (`model/model_meta.go`, `model/vendor_meta.go`, `model/prefill_group.go`).
  – Added `GetVendorModelCounts()` and `Is*NameDuplicated()` helpers; controllers now call these instead of duplicating queries.
  – API response for `/api/models` now returns `vendor_counts` with per-vendor totals across all pages, plus `all` summary.
  – Removed redundant checks and unused imports, eliminating `go vet` warnings.

• Frontend
  – `useModelsData.js` updated to consume backend-supplied `vendor_counts`, calculate the `all` total once, and drop legacy client-side counting logic.
  – Simplified initial data flow: first render now triggers only one models request.
  – Deleted obsolete `updateVendorCounts` helper and related comments.
  – Ensured search flow also sets `vendorCounts`, keeping tab badges accurate.

Why
This refactor enforces single-responsibility (aggregation in model layer), delivers consistent totals irrespective of pagination, and removes redundant client queries, leading to cleaner code and better performance.
2025-08-06 01:40:08 +08:00
neotf
24aa29598a feat: add support for claude-opus-4-1 model and update ratios 2025-08-06 00:58:46 +08:00
t0ng7u
d61a862fa2 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-05 23:19:24 +08:00
RedwindA
e29c6b44c7 fix(web): 修复模型倍率设置中添加新模型时输入框锁定的问题 2025-08-05 23:18:42 +08:00
t0ng7u
327a0ca323 🚀 refactor: refine pricing refresh logic & hide disabled models
Summary
-------
1. Pricing generation
   • `model/pricing.go`: skip any model whose `status != 1` when building
     `pricingMap`, ensuring disabled models are never returned to the
     front-end.

2. Cache refresh placement
   • `controller/model_meta.go`
     – Removed `model.RefreshPricing()` from pure read handlers
       (`GetAllModelsMeta`, `SearchModelsMeta`).
     – Kept refresh only in mutating handlers
       (`Create`, `Update`, `Delete`), guaranteeing data is updated
       immediately after an admin change while avoiding redundant work
       on every read.

Result
------
Front-end no longer receives information about disabled models, and
pricing cache refreshes occur exactly when model data is modified,
improving efficiency and consistency.
2025-08-05 23:18:12 +08:00
creamlike1024
a746309a8e fix: responses 流 cache token 未计费 2025-08-05 23:08:08 +08:00
wzxjohn
d247f90571 feat: support aws bedrock apikey 2025-08-05 23:01:30 +08:00
creamlike1024
edbe18b157 fix: responses cache token 未计费 2025-08-05 22:56:27 +08:00
t0ng7u
d951485431 feat: enhance soft-delete handling & boost pricing cache performance
Summary
-------
This commit unifies soft-delete behaviour across meta tables and
introduces an in-memory cache for model pricing look-ups to improve
throughput under high concurrency.

Details
-------
Soft-delete consistency
• PrefillGroup / Vendor / Model
  – Added `gorm.DeletedAt` field with `json:"-" gorm:"index"`.
  – Replaced plain `uniqueIndex` with partial unique indexes
    `uniqueIndex:<name>,where:deleted_at IS NULL`
    allowing duplicate keys after logical deletion while preserving
    uniqueness for active rows.
• Imports updated to include `gorm.io/gorm`.
• JSON output now hides `deleted_at`, matching existing tables.

High-throughput pricing cache
• model/pricing.go
  – Added thread-safe maps `modelEnableGroups` & `modelQuotaTypeMap`
    plus RW-mutex for O(1) access.
  – `updatePricing()` now refreshes these maps alongside `pricingMap`.
• model/model_extra.go
  – Rewrote `GetModelEnableGroups` & `GetModelQuotaType` to read from
    the new maps, falling back to automatic refresh via `GetPricing()`.

Misc
• Retained `RefreshPricing()` helper for immediate cache invalidation
  after admin actions.
• All modified files pass linter; no breaking DB migrations required
  (handled by AutoMigrate).

Result
------
– Soft-delete logic is transparent, safe, and allows record “revival”.
– Pricing-related queries are now constant-time, reducing CPU usage and
  latency under load.
2025-08-05 22:26:19 +08:00
Calcium-Ion
306a1a3f57 Merge pull request #1507 from QuantumNous/multi-key-manage
feat: implement channel-specific locking for thread-safe polling
2025-08-05 20:40:26 +08:00
CaIon
2431de78fa fix: reorder request URL handling for relay formats in Adaptor 2025-08-05 20:40:00 +08:00
t0ng7u
f3a1f98add 🐛 fix(models): eliminate vendor column flicker by loading vendors before models
Why:
• The vendor list API is separate from the models API, causing the “Vendor” column in `ModelsTable` to flash (rendering `'-'` first, then updating) after the table finishes loading.
• This visual jump degrades the user experience.

What:
• Updated `web/src/hooks/models/useModelsData.js`
  – In the initial `useEffect`, vendors are fetched first with `loadVendors()` and awaited.
  – Only after vendors are ready do we call `loadModels()`, ensuring `vendorMap` is populated before the table renders.

Outcome:
• The table now renders with complete vendor data on first paint, removing the flicker and providing a smoother UI.
2025-08-04 22:11:13 +08:00
t0ng7u
1ccc728e5d 💄 fix(pricing-card): align skeleton responsive grid with actual card layout
- Update PricingCardSkeleton grid classes from 'sm:grid-cols-2 lg:grid-cols-3'
  to 'xl:grid-cols-2 2xl:grid-cols-3' to match PricingCardView layout
- Ensures consistent column count between skeleton and actual content
  at same screen sizes
- Improves loading state visual consistency across different breakpoints
2025-08-04 22:03:12 +08:00
t0ng7u
11ee80d377 🍎 chore: modify the JSONEditor component import path 2025-08-04 21:58:10 +08:00
t0ng7u
512850e83d Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-04 21:37:38 +08:00
t0ng7u
0e9c3cde7c 🏗️ refactor: Replace model categories with vendor-based filtering and optimize data structure
- **Backend Changes:**
  - Refactor pricing API to return separate vendors array with ID-based model references
  - Remove redundant vendor_name/vendor_icon fields from pricing records, use vendor_id only
  - Add vendor_description to pricing response for frontend display
  - Maintain 1-minute cache protection for pricing endpoint security

- **Frontend Data Flow:**
  - Update useModelPricingData hook to build vendorsMap from API response
  - Enhance model records with vendor info during data processing
  - Pass vendorsMap through component hierarchy for consistent vendor data access

- **UI Component Replacements:**
  - Replace PricingCategories with PricingVendors component for vendor-based filtering
  - Replace PricingCategoryIntro with PricingVendorIntro in header section
  - Remove all model category related components and logic

- **Header Improvements:**
  - Implement vendor intro with real backend data (name, icon, description)
  - Add text collapsible feature (2-line limit with expand/collapse functionality)
  - Support carousel animation for "All Vendors" view with vendor icon rotation

- **Model Detail Modal Enhancements:**
  - Update ModelHeader to use real vendor icons via getLobeHubIcon()
  - Move tags from header to ModelBasicInfo content area to avoid SideSheet title width constraints
  - Display only custom tags from backend with stringToColor() for consistent styling
  - Use Space component with wrap property for proper tag layout

- **Table View Optimizations:**
  - Integrate RenderUtils for description and tags columns
  - Implement renderLimitedItems for tags (max 3 visible, +x popover for overflow)
  - Use renderDescription for text truncation with tooltip support

- **Filter Logic Updates:**
  - Vendor filter shows disabled options instead of hiding when no models match
  - Include "Unknown Vendor" category for models without vendor information
  - Remove all hardcoded vendor descriptions, use real backend data

- **Code Quality:**
  - Fix import paths after component relocation
  - Remove unused model category utilities and hardcoded mappings
  - Ensure consistent vendor data usage across all pricing views
  - Maintain backward compatibility with existing pricing calculation logic

This refactor provides a more scalable vendor-based architecture while eliminating
data redundancy and improving user experience with real-time backend data integration.
2025-08-04 21:36:31 +08:00
CaIon
8cce3cc84a feat: implement channel-specific locking for thread-safe polling 2025-08-04 20:44:19 +08:00
Calcium-Ion
faaa5a2949 Merge pull request #1499 from QuantumNous/multi-key-manage
feat: improve layout and pagination handling in MultiKeyManageModal
2025-08-04 20:17:22 +08:00
CaIon
c00f5a17c8 feat: improve layout and pagination handling in MultiKeyManageModal 2025-08-04 20:16:51 +08:00
Calcium-Ion
9c079d04a8 Merge pull request #1487 from seefs001/feature/2fa
feat: implement two-factor authentication (2FA) support with user login and settings integration
2025-08-04 19:54:31 +08:00
Calcium-Ion
c9d4cdc57e Merge pull request #1498 from QuantumNous/multi-key-manage
feat: add multi-key management
2025-08-04 19:53:52 +08:00
CaIon
12b4e80d4b feat: add status filtering and bulk enable/disable functionality in multi-key management 2025-08-04 19:51:58 +08:00
CaIon
6e2a04f374 fix: correct option value for pagination in MultiKeyManageModal 2025-08-04 19:33:24 +08:00
CaIon
8357b15fec feat: enhance multi-key management with pagination and statistics 2025-08-04 17:15:32 +08:00
CaIon
ecdd9d1ccb feat: add multi-key management 2025-08-04 16:52:31 +08:00
t0ng7u
fc69f4f757 feat: add model name matching rules with priority-based lookup
Add flexible model name matching system to support different matching patterns:

Backend changes:
- Add `name_rule` field to Model struct with 4 matching types:
  * 0: Exact match (default)
  * 1: Prefix match
  * 2: Contains match
  * 3: Suffix match
- Implement `FindModelByNameWithRule` function with priority order:
  exact > prefix > suffix > contains
- Add database migration for new `name_rule` column

Frontend changes:
- Add "Match Type" column in models table with colored tags
- Add name rule selector in create/edit modal with validation
- Auto-set exact match and disable selection for preconfigured models
- Add explanatory text showing priority order
- Support i18n for all new UI elements

This enables users to define model patterns once and reuse configurations
across similar models, reducing repetitive setup while maintaining exact
match priority for specific overrides.

Closes: #[issue-number]
2025-08-04 16:01:56 +08:00
t0ng7u
5e70274003 💰 feat: Add model billing type (quota_type) support across backend & frontend
Summary
• Backend
  1. model/model_meta.go
     – Added `QuotaType` field to `Model` struct (JSON only, gorm `-`).
  2. model/model_groups.go
     – Implemented `GetModelQuotaType(modelName)` leveraging cached pricing map.
  3. controller/model_meta.go
     – Enhanced `fillModelExtra` to populate `QuotaType` using new helper.

• Frontend
  1. web/src/components/table/models/ModelsColumnDefs.js
     – Introduced `renderQuotaType` helper that visualises billing mode with coloured tags (`teal = per-call`, `violet = per-token`).
     – Added “计费类型” column (`quota_type`) to models table.

Why
Providing the billing mode alongside existing pricing/group information gives administrators instant visibility into whether each model is priced per call or per token, aligning UI with new backend metadata.

Notes
No database migration required – `quota_type` is transient, delivered via API. Frontend labels/colours can be adjusted via i18n or theme tokens if necessary.
2025-08-04 15:38:01 +08:00
t0ng7u
57b194c63f Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-04 10:00:27 +08:00
Xyfacai
10b04416c1 fix: 修复gemini2openai 没有返回 usage 2025-08-04 09:06:57 +08:00
t0ng7u
9f6027325c feat: Add prefill group management system for models
- Add new PrefillGroup model with CRUD operations
  * Support for model, tag, and endpoint group types
  * JSON storage for group items with GORM datatypes
  * Automatic database migration support

- Implement backend API endpoints
  * GET /api/prefill_group - List groups by type with admin auth
  * POST /api/prefill_group - Create new groups
  * PUT /api/prefill_group - Update existing groups
  * DELETE /api/prefill_group/:id - Delete groups

- Add comprehensive frontend management interface
  * PrefillGroupManagement component for group listing
  * EditPrefillGroupModal for group creation/editing
  * Integration with EditModelModal for auto-filling
  * Responsive design with CardTable and SideSheet

- Enhance model editing workflow
  * Tag group selection with auto-fill functionality
  * Endpoint group selection with auto-fill functionality
  * Seamless integration with existing model forms

- Create reusable UI components
  * Extract common rendering utilities to models/ui/
  * Shared renderLimitedItems and renderDescription functions
  * Consistent styling across all model-related components

- Improve user experience
  * Empty state illustrations matching existing patterns
  * Fixed column positioning for operation buttons
  * Item content display with +x indicators for overflow
  * Tooltip support for long descriptions
2025-08-04 02:54:37 +08:00
t0ng7u
b64c8ea56b 🚀 feat: expose “Enabled Groups” for models with real-time refresh
Backend
• model/model_meta.go
  – Added `EnableGroups []string` to Model struct
  – fillModelExtra now populates EnableGroups

• model/model_groups.go
  – New helper `GetModelEnableGroups` (reuses Pricing cache)

• model/pricing_refresh.go
  – Added `RefreshPricing()` to force immediate cache rebuild

• controller/model_meta.go
  – `GetAllModelsMeta` & `SearchModelsMeta` call `model.RefreshPricing()` before querying, ensuring groups / endpoints are up-to-date

Frontend
• ModelsColumnDefs.js
  – Added `renderGroups` util and “可用分组” table column displaying color-coded tags

Result
Admins can now see which user groups can access each model, and any ability/group changes are reflected instantly without the previous 1-minute delay.
2025-08-04 00:00:51 +08:00
t0ng7u
e74d3f4a8f feat: polish “Missing Models” UX & mobile actions layout
Overview
• Re-designed `MissingModelsModal` to align with `ModelTestModal` and deliver a cleaner, paginated experience.
• Improved mobile responsiveness for action buttons in `ModelsActions`.

Details
1. MissingModelsModal.jsx
   • Switched from `List` to `Table` for a more structured view.
   • Added search bar with live keyword filtering and clear icon.
   • Implemented pagination via `MODEL_TABLE_PAGE_SIZE`; auto-resets on search.
   • Dynamic rendering: when no data, show unified Empty state without column header.
   • Enhanced header layout with total-count subtitle and modal corner rounding.
   • Removed unused `Typography.Text` import.

2. ModelsActions.jsx
   • Set “Delete Selected Models” and “Missing Models” buttons to `flex-1 md:flex-initial`, placing them on the same row as “Add Model” on small screens.

Result
The “Missing Models” workflow now offers quicker discovery, a familiar table interface, and full mobile friendliness—without altering API behavior.
2025-08-03 22:51:24 +08:00
t0ng7u
8a2aebf845 feat(edit-vendor-modal): add icon-library reference link & tidy status switch
Highlights
• Introduced `Typography.Text` link with `IconLink` in `extraText` for the **icon** field, pointing to LobeHub’s full icon catalogue; only “请点击我” is clickable for clarity.
• Added required imports for `Typography` and `IconLink`.
• Removed unnecessary `size="large"` prop from the status `Form.Switch` to align with default form styling.

These tweaks improve user guidance when selecting vendor icons and refine the modal’s visual consistency.
2025-08-03 19:45:58 +08:00
t0ng7u
984c8ee477 ♻️ refactor(models-table): extract reusable renderLimitedItems for list popovers
Introduce a generic `renderLimitedItems` helper within `ModelsColumnDefs.js` to eliminate duplicated logic for list-style columns.

Key changes
• Added `renderLimitedItems` to handle item limiting, “+N” indicator, and popover display.
• Migrated `renderTags`, `renderEndpoints`, and `renderBoundChannels` to use the new helper.
• Removed redundant inline implementations, reducing complexity and improving readability.
• Preserved previous UX: first 3 items shown, overflow accessible via popover.

This refactor streamlines code maintenance and ensures consistent behavior across related columns.
2025-08-03 19:31:29 +08:00
Seefs
398ae7156b refactor: improve error handling and database transactions in 2FA model methods 2025-08-03 10:49:55 +08:00
Seefs
d85eeabf11 fix: coderabbit review 2025-08-03 10:41:00 +08:00
t0ng7u
6a62654759 Merge branch 'alpha' into refactor/model-pricing 2025-08-02 22:26:40 +08:00
CaIon
c056a7ad7c feat: add support for multi-key channels in RelayInfo and access token caching 2025-08-02 22:12:15 +08:00
Seefs
c784a70277 feat: implement two-factor authentication (2FA) support with user login and settings integration 2025-08-02 14:53:28 +08:00
Calcium-Ion
e6c87907d5 Merge pull request #1486 from nekohy/fix-get-google-models
fix: correct Gemini channel model retrieval logic
2025-08-02 14:52:22 +08:00
Nekohy
71e9290142 fix: correct Gemini channel model retrieval logic 2025-08-02 14:19:32 +08:00
CaIon
74ec34da67 fix: improve error handling and readability in ability.go 2025-08-02 14:06:12 +08:00
CaIon
7188749cb3 feat: truncate abilities table before processing channels 2025-08-02 13:39:53 +08:00
CaIon
c28add55db feat: add caching for keys in channel structure and retain polling index during sync 2025-08-02 13:16:30 +08:00
CaIon
78f34a8245 feat: retain polling index for multi-key channels during sync 2025-08-02 13:04:48 +08:00
CaIon
97d6f10f15 feat: enhance ConvertGeminiRequest to set default role and handle YouTube video MIME type 2025-08-02 12:53:58 +08:00
Calcium-Ion
afefc4caca Merge pull request #1484 from QuantumNous/ConvertGeminiRequest
feat: Convert gemini request
2025-08-02 12:20:39 +08:00
CaIon
6abbd036f8 feat: add recordErrorLog option to NewAPIError for conditional error logging 2025-08-02 11:07:50 +08:00
CaIon
ef0db0f914 feat: implement key mode for multi-key channels with append/replace options 2025-08-02 10:57:03 +08:00
creamlike1024
e01986fdd4 Merge remote-tracking branch 'origin/alpha' into ConvertGeminiRequest 2025-08-01 22:42:48 +08:00
creamlike1024
a0c6ebe2d8 chore: remove debug log 2025-08-01 22:29:19 +08:00
creamlike1024
d2183af23f feat: convert gemini format to openai chat completions 2025-08-01 22:23:35 +08:00
CaIon
953f1bdc3c feat: add admin info to error logging with multi-key support 2025-08-01 18:19:28 +08:00
CaIon
e2429f20f8 fix: ensure ChannelIsMultiKey context key is set to false for single key retries 2025-08-01 18:09:20 +08:00
CaIon
f0945da4fb refactor: simplify streamResponseGeminiChat2OpenAI by removing hasImage return value and optimizing response text handling 2025-08-01 17:58:21 +08:00
CaIon
8df3de9ae5 fix: update JSONEditor to default to manual mode for invalid JSON and add error message for invalid data 2025-08-01 17:21:25 +08:00
Calcium-Ion
277cc1cac8 Merge pull request #1481 from seefs001/revert-1445-feature/claude-code
Revert "feat: add Claude Code channel support with OAuth integration"
2025-08-01 17:05:22 +08:00
CaIon
07a92293e4 fix: handle case where no response is received from Gemini API 2025-08-01 17:04:16 +08:00
t0ng7u
9730b9ba2d feat(ui): enhance tag input
1. EditModelModal quality-of-life
   • Added comma parsing to `Form.TagInput`; users can now paste
     `tag1, tag2 , tag3` to bulk-create tags.
   • Updated placeholder copy to reflect the new capability.

All files pass linting; no runtime changes outside the intended UI updates.
2025-08-01 03:00:12 +08:00
t0ng7u
508799c452 🎨 style(sidebar): unify highlight color & assign unique icon for Models
• Removed obsolete `sidebarIconColors` map and `getItemColor` util from
  SiderBar/render; all selected states now use the single CSS variable
  `--semi-color-primary` for both text and icons.
• Simplified `getLucideIcon`:
  – Added `Package` to Lucide imports.
  – Switched “models” case to `<Package />`, avoiding duplication with
    the Layers glyph.
  – Replaced per-key color logic with `iconColor` derived from the new
    uniform highlight color.
• Stripped any unused imports / dead code paths after the refactor.
• Lint passes; sidebar hover/focus behavior unchanged while visual
  consistency is improved.
2025-08-01 02:50:06 +08:00
t0ng7u
5e81ef4a44 ♻️ refactor(hooks/models): deduplicate useModelsData and optimize vendor-tab counts
Highlights
──────────
1. Removed code duplication
   • Introduced `extractItems` helper to safely unwrap API payloads.
   • Simplified `getFormValues` to a single-line fallback expression.
   • Replaced repeated list-extraction code in `loadModels`, `searchModels`,
     and `refreshVendorCounts` with the new helper.

2. Vendor tab accuracy & performance
   • Added `refreshVendorCounts` to recalc counts via a single lightweight
     request; invoked only when必要 (current tab ≠ "all“) to avoid redundancy.
   • `loadModels` still updates counts instantly when viewing "all", ensuring
     accurate numbers on initial load and page changes.

3. Misc clean-ups
   • Streamlined conditional URL building and state updates.
   • Confirmed all async branches include error handling with i18n messages.
   • Ran linter → zero issues.

Result: leaner, easier-to-maintain hook with correct, real-time vendor counts
and no repeated logic.
2025-08-01 02:39:12 +08:00
t0ng7u
eb42eb6f27 🐛 fix(model): preserve created_time on Model update and streamline field maintenance
The update operation for Model previously overwrote `created_time` with zero
because GORM included every struct field in the UPDATE statement.
This commit adjusts `Model.Update()` to:

* Call `Omit("created_time")` so the creation timestamp is never modified.
* Refresh `UpdatedTime` with `common.GetTimestamp()` before persisting.
* Delegate the remainder of the struct to GORM, eliminating the need to
  maintain an explicit allow-list whenever new fields are introduced.

No API contract is changed; existing CRUD endpoints continue to work
normally while data integrity for historical records is now guaranteed.
2025-08-01 02:21:14 +08:00
t0ng7u
232612898b 🔄 fix: improve vendor-tab filtering & counts, resolve SQL ambiguity, and reload data correctly
Backend
• model/model_meta.go
  – Import strconv
  – SearchModels: support numeric vendor ID filter vs. fuzzy name search
  – Explicitly order by `models.id` to avoid “ambiguous column name: id” error

Frontend
• hooks/useModelsData.js
  – Change vendor-filter API to pass vendor ID
  – Automatically reload models when `activeVendorKey` changes
  – Update vendor counts only when viewing “All” to preserve other tab totals
• Add missing effect in EditModelModal to refresh vendor list only when modal visible
• Other minor updates to keep lints clean

Result
Tabs now:
1. Trigger API requests on click
2. Show accurate per-vendor totals
3. Filter models without resetting other counts
Backend search handles both vendor IDs and names without SQL errors.
2025-07-31 23:30:45 +08:00
t0ng7u
6a37efb871 Merge branch 'alpha' into refactor/model-pricing 2025-07-31 22:28:59 +08:00
t0ng7u
af59b61f8a 🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements
Backend
• Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps
• Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go`
• Auto-migrate new tables in DB startup logic

Frontend
• Build complete “Model Management” module under `/console/models`
  - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs
  - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile`
• Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature
• Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes

Table UX improvements
• Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style)
• Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags
• Color all tags deterministically using `stringToColor` for consistent theming
• Change vendor column tag color to white for better contrast

Misc
• Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up

These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
Seefs
f995e31d04 Revert "feat: add Claude Code channel support with OAuth integration" 2025-07-31 22:08:16 +08:00
Calcium-Ion
9758a9e60d Merge pull request #1445 from seefs001/feature/claude-code
feat: add Claude Code channel support with OAuth integration
2025-07-31 21:28:23 +08:00
Seefs
6f56696af2 fix: handle authorization code format in ExchangeCode function and update placeholder in EditChannelModal 2025-07-31 21:27:24 +08:00
Seefs
345fbdf3d2 Merge branch 'alpha' into feature/claude-code
# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
2025-07-31 21:19:43 +08:00
t0ng7u
82bf149ade Merge branch 'alpha' into refactor/model-pricing 2025-07-31 00:41:01 +08:00
t0ng7u
4cc76f2deb Merge branch 'alpha' into refactor/model-pricing 2025-07-27 09:51:35 +08:00
t0ng7u
b41c24d653 Merge branch 'alpha' into refactor/model-pricing 2025-07-27 00:03:18 +08:00
Seefs
fe9acb6c59 chore: claude code automatic disable 2025-07-26 18:40:18 +08:00
t0ng7u
75548c449b refactor: pricing filters for dynamic counting & cleaner logic
This commit introduces a unified, maintainable solution for all model-pricing filter buttons and removes redundant code.

Key points
• Added `usePricingFilterCounts` hook
  - Centralises filtering logic and returns:
    - `quotaTypeModels`, `endpointTypeModels`, `dynamicCategoryCounts`, `groupCountModels`
  - Keeps internal helpers private (removed public `modelsAfterCategory`).

• Updated components to consume the new hook
  - `PricingSidebar.jsx`
  - `FilterModalContent.jsx`

• Improved button UI/UX
  - `SelectableButtonGroup.jsx` now respects `item.disabled` and auto-disables when `tagCount === 0`.
  - `PricingGroups.jsx` counts models per group (after all other filters) and disables groups with zero matches.
  - `PricingEndpointTypes.jsx` enumerates all endpoint types, computes filtered counts, and disables entries with zero matches.

• Removed obsolete / duplicate calculations and comments to keep components lean.

The result is consistent, real-time tag counts across all filter groups, automatic disabling of unavailable options, and a single source of truth for filter computations, making future extensions straightforward.
2025-07-26 18:38:18 +08:00
Seefs
bca78beb1b feat: add claude code channel 2025-07-26 18:06:46 +08:00
t0ng7u
9110611489 Merge branch 'alpha' into refactor/model-pricing 2025-07-26 17:21:47 +08:00
t0ng7u
0b1a1ca064 refactor: Restructure model pricing components and improve UX consistency
- **Fix SideSheet double-click issue**: Remove early return for null modelData to prevent rendering blockage during async state updates
- **Component modularization**:
  - Split ModelDetailSideSheet into focused sub-components (ModelHeader, ModelBasicInfo, ModelEndpoints, ModelPricingTable)
  - Refactor PricingFilterModal with FilterModalContent and FilterModalFooter components
  - Remove unnecessary FilterSection wrapper for cleaner interface
- **Improve visual consistency**:
  - Unify avatar/icon logic between ModelHeader and PricingCardView components
  - Standardize tag colors across all pricing components (violet/teal for billing types)
  - Apply consistent dashed border styling using Semi UI theme colors
- **Enhance data accuracy**:
  - Display raw endpoint type names (e.g., "openai", "anthropic") instead of translated descriptions
  - Remove text alignment classes for better responsive layout
  - Add proper null checks to prevent runtime errors
- **Code quality improvements**:
  - Reduce component complexity by 52-74% through modularization
  - Improve maintainability with single responsibility principle
  - Add comprehensive error handling for edge cases

This refactoring improves component reusability, reduces bundle size, and provides a more consistent user experience across the model pricing interface.
2025-07-26 04:24:22 +08:00
t0ng7u
52a9cee0e1 Merge branch 'alpha' into refactor/model-pricing 2025-07-25 20:33:14 +08:00
t0ng7u
34d45bb3b8 🍭 style(ui): Optimize style layout and improve responsive layout 2025-07-24 23:28:55 +08:00
t0ng7u
aecdbfacf3 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-07-24 17:47:08 +08:00
t0ng7u
1c25e29999 📱 fix(ui): adjust responsive breakpoints for pricing card grid layout
Optimize grid column breakpoints to account for 460px sidebar width:
- Change from sm:grid-cols-2 lg:grid-cols-3 to xl:grid-cols-2 2xl:grid-cols-3
- Ensures adequate space for card display after subtracting sidebar width
- Improves layout on medium-sized screens where previous breakpoints caused cramped display

Breakpoint calculation:
- 1280px screen - 460px sidebar = 820px → 2 columns
- 1536px screen - 460px sidebar = 1076px → 3 columns
2025-07-24 17:44:48 +08:00
t0ng7u
5ceb898676 ♻️ refactor(utils): optimize resetPricingFilters function for better maintainability (#1365)
- Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration
- Replace verbose type checks with optional chaining operator (?.) for cleaner code
- Eliminate redundant function type validations and comments
- Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality
- Improve code readability and follow modern JavaScript best practices

This refactoring enhances code quality without changing the function's behavior,
making it easier to maintain and modify default filter values in the future.
2025-07-24 17:22:20 +08:00
t0ng7u
2fe3706ef0 ♻️ refactor(utils): optimize resetPricingFilters function for better maintainability (#1365)
- Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration
- Replace verbose type checks with optional chaining operator (?.) for cleaner code
- Eliminate redundant function type validations and comments
- Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality
- Improve code readability and follow modern JavaScript best practices

This refactoring enhances code quality without changing the function's behavior,
making it easier to maintain and modify default filter values in the future.
2025-07-24 17:15:28 +08:00
t0ng7u
1880164e29 ♻️ Refactor: Move token unit toggle from table header to filter settings
- Remove K/M switch from model price column header in pricing table
- Add "Display in K units" option to pricing display settings panel
- Update parameter passing for tokenUnit and setTokenUnit across components:
  - PricingDisplaySettings: Add tokenUnit toggle functionality
  - PricingSidebar: Pass tokenUnit props to display settings
  - PricingFilterModal: Include tokenUnit in mobile filter modal
- Enhance resetPricingFilters utility to reset token unit to default 'M'
- Clean up PricingTableColumns by removing unused setTokenUnit parameter
- Add English translation for "按K显示单位" as "Display in K units"

This change improves UX by consolidating all display-related controls
in the filter settings panel, making the interface more organized and
the token unit setting more discoverable alongside other display options.

Affected components:
- PricingTableColumns.js
- PricingDisplaySettings.jsx
- PricingSidebar.jsx
- PricingFilterModal.jsx
- PricingTable.jsx
- utils.js (resetPricingFilters)
- en.json (translations)
2025-07-24 17:10:08 +08:00
t0ng7u
e417c269eb 🖼️ style(ui): change skeleton button size to 16*16 2025-07-24 03:29:48 +08:00
t0ng7u
59a76b3970 feat: Add endpoint type filter to model pricing system
- Create PricingEndpointTypes.jsx component for endpoint type filtering
- Add filterEndpointType state management in useModelPricingData hook
- Integrate endpoint type filtering logic in filteredModels computation
- Update PricingSidebar.jsx to include endpoint type filter component
- Update PricingFilterModal.jsx to support endpoint type filtering on mobile
- Extend resetPricingFilters utility function to include endpoint type reset
- Support filtering models by endpoint types (OpenAI, Anthropic, Gemini, etc.)
- Display model count for each endpoint type with localized labels
- Ensure filter state resets to first page when endpoint type changes

This enhancement allows users to filter models by their supported endpoint types,
providing more granular control over model selection in the pricing interface.
2025-07-24 03:25:57 +08:00
t0ng7u
53be79a00e 💄 style(pricing): enhance card view UI and skeleton loading experience (#1365)
- Increase skeleton card count from 6 to 10 for better visual coverage
- Extend minimum skeleton display duration from 500ms to 1000ms for smoother UX
- Add circle shape to all pricing tags for consistent rounded design
- Apply circle styling to billing type, popularity, endpoint, and context tags

This commit improves the visual consistency and user experience of the pricing
card view by standardizing tag appearance and optimizing skeleton loading timing.
2025-07-24 03:19:32 +08:00
t0ng7u
c4b69b341a Merge branch 'alpha' into refactor/model-pricing 2025-07-24 01:35:59 +08:00
t0ng7u
a99dbc78c9 ♻️ refactor(model-pricing): improve table UI and optimize code structure (#1365)
- Replace model count with group ratio display (x2.2, x1) in group filter
- Remove redundant "Available Groups" column from pricing table
- Remove "Availability" column and related logic completely
- Move "Supported Endpoint Types" column to fixed right position
- Clean up unused parameters and variables in PricingTableColumns.js
- Optimize variable declarations (let → const) and simplify render logic
- Improve code readability and reduce memory allocations

This refactor enhances user experience by:
- Providing clearer group ratio information in filters
- Simplifying table layout while maintaining essential functionality
- Improving performance through better code organization

Breaking changes: None
2025-07-23 11:20:55 +08:00
t0ng7u
8a54512037 🔧 fix: filter out empty string group from pricing groups selector (#1365)
Filter out the special empty string group ("": "用户分组") from the
usable groups in PricingGroups component. This empty group represents
"user's current group" but contains no data and should not be displayed
in the group filter options.

- Add filter condition to exclude empty string keys from usableGroup
- Prevents displaying invalid empty group option in UI
- Improves user experience by showing only valid selectable groups
2025-07-23 10:04:32 +08:00
t0ng7u
3f96bd9509 feat: Add skeleton loading animation to SelectableButtonGroup component (#1365)
Add comprehensive loading state support with skeleton animations for the SelectableButtonGroup component, improving user experience during data loading.

Key Changes:
- Add loading prop to SelectableButtonGroup with minimum 500ms display duration
- Implement skeleton buttons with proper Semi-UI Skeleton wrapper and active animation
- Use fixed skeleton count (6 items) to prevent visual jumping during load transitions
- Pass loading state through all pricing filter components hierarchy:
  - PricingSidebar and PricingFilterModal as container components
  - PricingDisplaySettings, PricingCategories, PricingGroups, PricingQuotaTypes as filter components

Technical Details:
- Reference CardTable.js implementation for consistent skeleton UI patterns
- Add useEffect hook for 500ms minimum loading duration control
- Support both checkbox and regular button skeleton modes
- Maintain responsive layout compatibility (mobile/desktop)
- Add proper JSDoc parameter documentation for loading prop

Fixes:
- Prevent skeleton count sudden changes that caused visual discontinuity
- Ensure proper skeleton animation with Semi-UI active parameter
- Maintain consistent loading experience across all filter components
2025-07-23 04:31:27 +08:00
t0ng7u
6d06cb8fb3 feat: enhance SelectableButtonGroup with checkbox support and refactor pricing display settings (#1365)
- Add withCheckbox prop to SelectableButtonGroup component for checkbox-prefixed buttons
- Support both single value and array activeValue for multi-selection scenarios
- Refactor PricingDisplaySettings to use consistent SelectableButtonGroup styling
- Replace Switch components with checkbox-enabled SelectableButtonGroup
- Replace Select dropdown with SelectableButtonGroup for currency selection
- Maintain unified UI/UX across all pricing filter components
- Add proper JSDoc documentation for new withCheckbox functionality

This improves visual consistency and provides a more cohesive user experience
in the model pricing filter interface.
2025-07-23 04:10:44 +08:00
t0ng7u
4247883173 💄 feat(ui): replace availability indicators with icons in PricingTableColumns (#1365)
Summary
• Swapped out the old availability UI for clearer icon-based feedback.
• Users now see a green check icon when their group can use a model and a red × icon (with tooltip) when it cannot.

Details
1. Imports
   • Removed deprecated `IconVerify`.
   • Added `IconCheckCircleStroked`  and `IconClose`  for new states.

2. Availability column
   • `renderAvailable` now
     – Shows a green `IconCheckCircleStroked` inside a popover (“Your group can use this model”).
     – Shows a red `IconClose` inside a popover (“你的分组无权使用该模型”) when the model is inaccessible.
     – Eliminates the empty cell/grey tag fallback.

3. Group tag
   • Updated selected-group tag to use `IconCheckCircleStroked` for visual consistency.

Result
Improves UX by providing explicit visual cues for model availability and removes ambiguous blank cells.
2025-07-23 03:41:19 +08:00
t0ng7u
bf491d6fe7 ♻️ refactor(model-pricing): extract resetPricingFilters utility and eliminate duplication (#1365)
Centralize filter-reset logic to improve maintainability and consistency.

- Add `resetPricingFilters` helper to `web/src/helpers/utils.js`, encapsulating all reset actions (search, category, currency, ratio, group, quota type, etc.).
- Update `PricingFilterModal.jsx` and `PricingSidebar.jsx` to import and use the new utility instead of keeping their own duplicate `handleResetFilters`.
- Removes repeated code, ensures future changes to reset behavior require modification in only one place, and keeps components lean.
2025-07-23 03:29:11 +08:00
t0ng7u
c15e753a0a 🔧 refactor(pricing-filters): extract display settings & improve mobile layout (#1365)
* **PricingDisplaySettings.jsx**
  • Extracted display settings (recharge price, currency, ratio toggle) from PricingSidebar
  • Maintains complete styling and functionality as standalone component

* **SelectableButtonGroup.jsx**
  • Added isMobile detection with conditional Col spans
  • Mobile: `span={12}` (2 buttons per row) for better touch experience
  • Desktop: preserved responsive grid `xs={24} sm={24} md={24} lg={12} xl={8}`

* **PricingSidebar.jsx**
  • Updated imports to use new PricingDisplaySettings component
  • Simplified component structure while preserving reset logic

These changes enhance code modularity and provide optimized mobile UX for filter button groups across the pricing interface.
2025-07-23 03:14:25 +08:00
t0ng7u
902aee4e6b 📌 fix(pricing-search): make search bar sticky within PricingContent (#1365)
* Added `position: sticky; top: 0; z-index: 5;` to search bar container
  – keeps the bar fixed while the table body scrolls
* Preserves previous padding, border and background styles
* Improves usability by ensuring quick access to search & actions during long list navigation

• PricingTable
  • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}`
  • Processes columns to remove `fixed` in compact mode

• PricingPage & index.css
  • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content`

• Responsive / style refinements
  • Sidebar width adjusted to 460px
  • Scrollbars hidden uniformly across pricing modules

These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability.
2025-07-23 02:28:43 +08:00
t0ng7u
b964f755ec feat(ui): enhance pricing table & filters with responsive button-group, fixed column, scroll tweaks (#1365)
• SelectableButtonGroup
  • Added optional collapsible support with gradient mask & toggle
  • Dynamic tagCount badge support for groups / quota types
  • Switched to responsive Row/Col (`xs 24`, `sm 24`, `lg 12`, `xl 8`) for fluid layout
  • Shows expand button only when item count exceeds visible rows

• Sidebar filters
  • PricingGroups & PricingQuotaTypes now pass tag counts to button-group
  • Counts derived from current models & quota_type

• PricingTableColumns
  • Moved “Availability” column to far right; fixed via `fixed: 'right'`
  • Re-ordered columns and preserved ratio / price logic

• PricingTable
  • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}`
  • Processes columns to remove `fixed` in compact mode

• PricingPage & index.css
  • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content`

• Responsive / style refinements
  • Sidebar width adjusted to 460px
  • Scrollbars hidden uniformly across pricing modules

These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability.
2025-07-23 02:23:25 +08:00
t0ng7u
a044070e1d 🎨 feat(model-pricing): refactor layout and component structure (#1365)
* Re-architected model-pricing page into modular components:
  * PricingPage / PricingSidebar / PricingContent
  * Removed obsolete `ModelPricing*` components and column defs
* Introduced reusable `SelectableButtonGroup` in `common/ui`
  * Supports Row/Col grid (3 per row)
  * Optional collapsible mode with gradient mask & toggle
* Rebuilt filter panels with the new button-group:
  * Model categories, token groups, and quota types
  * Added dynamic `tagCount` badges to display item totals
* Extended `useModelPricingData` hook
  * Added `filterGroup` and `filterQuotaType` state and logic
* Updated PricingTable columns & sidebar reset logic to respect new states
* Ensured backward compatibility via re-export in `index.jsx`
* Polished styling, icons and i18n keys
2025-07-23 01:58:51 +08:00
170 changed files with 12928 additions and 1983 deletions

View File

@@ -0,0 +1,32 @@
package common
import "one-api/constant"
// EndpointInfo 描述单个端点的默认请求信息
// path: 上游路径
// method: HTTP 请求方式,例如 POST/GET
// 目前均为 POST后续可扩展
//
// json 标签用于直接序列化到 API 输出
// 例如:{"path":"/v1/chat/completions","method":"POST"}
type EndpointInfo struct {
Path string `json:"path"`
Method string `json:"method"`
}
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
}
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
info, ok := defaultEndpointInfoMap[et]
return info, ok
}

150
common/totp.go Normal file
View File

@@ -0,0 +1,150 @@
package common
import (
"crypto/rand"
"fmt"
"os"
"strconv"
"strings"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const (
// 备用码配置
BackupCodeLength = 8 // 备用码长度
BackupCodeCount = 4 // 生成备用码数量
// 限制配置
MaxFailAttempts = 5 // 最大失败尝试次数
LockoutDuration = 300 // 锁定时间(秒)
)
// GenerateTOTPSecret 生成TOTP密钥和配置
func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
issuer := Get2FAIssuer()
return totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
}
// ValidateTOTPCode 验证TOTP验证码
func ValidateTOTPCode(secret, code string) bool {
// 清理验证码格式
cleanCode := strings.ReplaceAll(code, " ", "")
if len(cleanCode) != 6 {
return false
}
// 验证验证码
return totp.Validate(cleanCode, secret)
}
// GenerateBackupCodes 生成备用恢复码
func GenerateBackupCodes() ([]string, error) {
codes := make([]string, BackupCodeCount)
for i := 0; i < BackupCodeCount; i++ {
code, err := generateRandomBackupCode()
if err != nil {
return nil, err
}
codes[i] = code
}
return codes, nil
}
// generateRandomBackupCode 生成单个备用码
func generateRandomBackupCode() (string, error) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
code := make([]byte, BackupCodeLength)
for i := range code {
randomBytes := make([]byte, 1)
_, err := rand.Read(randomBytes)
if err != nil {
return "", err
}
code[i] = charset[int(randomBytes[0])%len(charset)]
}
// 格式化为 XXXX-XXXX 格式
return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
}
// ValidateBackupCode 验证备用码格式
func ValidateBackupCode(code string) bool {
// 移除所有分隔符并转为大写
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
if len(cleanCode) != BackupCodeLength {
return false
}
// 检查字符是否合法
for _, char := range cleanCode {
if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
return false
}
}
return true
}
// NormalizeBackupCode 标准化备用码格式
func NormalizeBackupCode(code string) string {
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
if len(cleanCode) == BackupCodeLength {
return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
}
return code
}
// HashBackupCode 对备用码进行哈希
func HashBackupCode(code string) (string, error) {
normalizedCode := NormalizeBackupCode(code)
return Password2Hash(normalizedCode)
}
// Get2FAIssuer 获取2FA发行者名称
func Get2FAIssuer() string {
return SystemName
}
// getEnvOrDefault 获取环境变量或默认值
func getEnvOrDefault(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
// ValidateNumericCode 验证数字验证码格式
func ValidateNumericCode(code string) (string, error) {
// 移除空格
code = strings.ReplaceAll(code, " ", "")
if len(code) != 6 {
return "", fmt.Errorf("验证码必须是6位数字")
}
// 检查是否为纯数字
if _, err := strconv.Atoi(code); err != nil {
return "", fmt.Errorf("验证码只能包含数字")
}
return code, nil
}
// GenerateQRCodeData 生成二维码数据
func GenerateQRCodeData(secret, username string) string {
issuer := Get2FAIssuer()
accountName := fmt.Sprintf("%s (%s)", username, issuer)
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
issuer, accountName, secret, issuer)
}

View File

@@ -11,7 +11,6 @@ const (
ContextKeyTokenKey ContextKey = "token_key"
ContextKeyTokenId ContextKey = "token_id"
ContextKeyTokenGroup ContextKey = "token_group"
ContextKeyTokenAllowIps ContextKey = "allow_ips"
ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"

View File

@@ -161,7 +161,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo))
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens))
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.GetMaxTokens()))
if err != nil {
return testResult{
context: c,
@@ -275,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
Quota: quota,
Content: "模型测试",
UseTimeSeconds: int(consumedTime),
IsStream: false,
IsStream: info.IsStream,
Group: info.UsingGroup,
Other: other,
})

View File

@@ -52,6 +52,13 @@ func parseStatusFilter(statusParam string) int {
}
}
func clearChannelInfo(channel *model.Channel) {
if channel.ChannelInfo.IsMultiKey {
channel.ChannelInfo.MultiKeyDisabledReason = nil
channel.ChannelInfo.MultiKeyDisabledTime = nil
}
}
func GetAllChannels(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0)
@@ -126,6 +133,10 @@ func GetAllChannels(c *gin.Context) {
}
}
for _, datum := range channelData {
clearChannelInfo(datum)
}
countQuery := model.DB.Model(&model.Channel{})
if statusFilter == common.ChannelStatusEnabled {
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
@@ -168,14 +179,26 @@ func FetchUpstreamModels(c *gin.Context) {
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
url := fmt.Sprintf("%s/v1/models", baseURL)
var url string
switch channel.Type {
case constant.ChannelTypeGemini:
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL)
// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
case constant.ChannelTypeAli:
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
var body []byte
key := strings.Split(channel.Key, "\n")[0]
if channel.Type == constant.ChannelTypeGemini {
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it
} else {
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key))
}
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
common.ApiError(c, err)
return
@@ -319,6 +342,10 @@ func SearchChannels(c *gin.Context) {
pagedData := channelData[startIdx:endIdx]
for _, datum := range pagedData {
clearChannelInfo(datum)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -342,6 +369,9 @@ func GetChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
if channel != nil {
clearChannelInfo(channel)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -669,6 +699,7 @@ func DeleteChannelBatch(c *gin.Context) {
type PatchChannel struct {
model.Channel
MultiKeyMode *string `json:"multi_key_mode"`
KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
}
func UpdateChannel(c *gin.Context) {
@@ -688,7 +719,7 @@ func UpdateChannel(c *gin.Context) {
return
}
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, false)
originChannel, err := model.GetChannelById(channel.Id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -704,6 +735,69 @@ func UpdateChannel(c *gin.Context) {
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
}
// 处理多key模式下的密钥追加/覆盖逻辑
if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey {
switch *channel.KeyMode {
case "append":
// 追加模式:将新密钥添加到现有密钥列表
if originChannel.Key != "" {
var newKeys []string
var existingKeys []string
// 解析现有密钥
if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") {
// JSON数组格式
var arr []json.RawMessage
if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil {
existingKeys = make([]string, len(arr))
for i, v := range arr {
existingKeys[i] = string(v)
}
}
} else {
// 换行分隔格式
existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n")
}
// 处理 Vertex AI 的特殊情况
if channel.Type == constant.ChannelTypeVertexAi {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "追加密钥解析失败: " + err.Error(),
})
return
}
newKeys = array
} else {
// 单个JSON密钥
newKeys = []string{channel.Key}
}
// 合并密钥
allKeys := append(existingKeys, newKeys...)
channel.Key = strings.Join(allKeys, "\n")
} else {
// 普通渠道的处理
inputKeys := strings.Split(channel.Key, "\n")
for _, key := range inputKeys {
key = strings.TrimSpace(key)
if key != "" {
newKeys = append(newKeys, key)
}
}
// 合并密钥
allKeys := append(existingKeys, newKeys...)
channel.Key = strings.Join(allKeys, "\n")
}
}
case "replace":
// 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理)
}
}
err = channel.Update()
if err != nil {
common.ApiError(c, err)
@@ -711,6 +805,7 @@ func UpdateChannel(c *gin.Context) {
}
model.InitChannelCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -914,3 +1009,413 @@ func CopyChannel(c *gin.Context) {
// success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
}
// MultiKeyManageRequest represents the request for multi-key management operations
type MultiKeyManageRequest struct {
ChannelId int `json:"channel_id"`
Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions
Page int `json:"page,omitempty"` // for get_key_status pagination
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
}
// MultiKeyStatusResponse represents the response for key status query
type MultiKeyStatusResponse struct {
Keys []KeyStatus `json:"keys"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
// Statistics
EnabledCount int `json:"enabled_count"`
ManualDisabledCount int `json:"manual_disabled_count"`
AutoDisabledCount int `json:"auto_disabled_count"`
}
type KeyStatus struct {
Index int `json:"index"`
Status int `json:"status"` // 1: enabled, 2: disabled
DisabledTime int64 `json:"disabled_time,omitempty"`
Reason string `json:"reason,omitempty"`
KeyPreview string `json:"key_preview"` // first 10 chars of key for identification
}
// ManageMultiKeys handles multi-key management operations
func ManageMultiKeys(c *gin.Context) {
request := MultiKeyManageRequest{}
err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(request.ChannelId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "渠道不存在",
})
return
}
if !channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该渠道不是多密钥模式",
})
return
}
lock := model.GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
switch request.Action {
case "get_key_status":
keys := channel.GetKeys()
// Default pagination parameters
page := request.Page
pageSize := request.PageSize
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50 // Default page size
}
// Statistics for all keys (unchanged by filtering)
var enabledCount, manualDisabledCount, autoDisabledCount int
// Build all key status data first
var allKeyStatusList []KeyStatus
for i, key := range keys {
status := 1 // default enabled
var disabledTime int64
var reason string
if channel.ChannelInfo.MultiKeyStatusList != nil {
if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
status = s
}
}
// Count for statistics (all keys)
switch status {
case 1:
enabledCount++
case 2:
manualDisabledCount++
case 3:
autoDisabledCount++
}
if status != 1 {
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i]
}
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
reason = channel.ChannelInfo.MultiKeyDisabledReason[i]
}
}
// Create key preview (first 10 chars)
keyPreview := key
if len(key) > 10 {
keyPreview = key[:10] + "..."
}
allKeyStatusList = append(allKeyStatusList, KeyStatus{
Index: i,
Status: status,
DisabledTime: disabledTime,
Reason: reason,
KeyPreview: keyPreview,
})
}
// Apply status filter if specified
var filteredKeyStatusList []KeyStatus
if request.Status != nil {
for _, keyStatus := range allKeyStatusList {
if keyStatus.Status == *request.Status {
filteredKeyStatusList = append(filteredKeyStatusList, keyStatus)
}
}
} else {
filteredKeyStatusList = allKeyStatusList
}
// Calculate pagination based on filtered results
filteredTotal := len(filteredKeyStatusList)
totalPages := (filteredTotal + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
// Calculate range for current page
start := (page - 1) * pageSize
end := start + pageSize
if end > filteredTotal {
end = filteredTotal
}
// Get the page data
var pageKeyStatusList []KeyStatus
if start < filteredTotal {
pageKeyStatusList = filteredKeyStatusList[start:end]
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": MultiKeyStatusResponse{
Keys: pageKeyStatusList,
Total: filteredTotal, // Total of filtered results
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
EnabledCount: enabledCount, // Overall statistics
ManualDisabledCount: manualDisabledCount, // Overall statistics
AutoDisabledCount: autoDisabledCount, // Overall statistics
},
})
return
case "disable_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要禁用的密钥索引",
})
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
return
}
if channel.ChannelInfo.MultiKeyStatusList == nil {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
}
if channel.ChannelInfo.MultiKeyDisabledTime == nil {
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
}
if channel.ChannelInfo.MultiKeyDisabledReason == nil {
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
}
channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已禁用",
})
return
case "enable_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要启用的密钥索引",
})
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
return
}
// 从状态列表中删除该密钥的记录,使其回到默认启用状态
if channel.ChannelInfo.MultiKeyStatusList != nil {
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
}
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex)
}
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex)
}
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已启用",
})
return
case "enable_all_keys":
// 清空所有禁用状态,使所有密钥回到默认启用状态
var enabledCount int
if channel.ChannelInfo.MultiKeyStatusList != nil {
enabledCount = len(channel.ChannelInfo.MultiKeyStatusList)
}
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已启用 %d 个密钥", enabledCount),
})
return
case "disable_all_keys":
// 禁用所有启用的密钥
if channel.ChannelInfo.MultiKeyStatusList == nil {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
}
if channel.ChannelInfo.MultiKeyDisabledTime == nil {
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
}
if channel.ChannelInfo.MultiKeyDisabledReason == nil {
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
}
var disabledCount int
for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ {
status := 1 // default enabled
if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
status = s
}
// 只禁用当前启用的密钥
if status == 1 {
channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled
disabledCount++
}
}
if disabledCount == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "没有可禁用的密钥",
})
return
}
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
})
return
case "delete_disabled_keys":
keys := channel.GetKeys()
var remainingKeys []string
var deletedCount int
var newStatusList = make(map[int]int)
var newDisabledTime = make(map[int]int64)
var newDisabledReason = make(map[int]string)
newIndex := 0
for i, key := range keys {
status := 1 // default enabled
if channel.ChannelInfo.MultiKeyStatusList != nil {
if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
status = s
}
}
// 只删除自动禁用status == 3的密钥保留启用status == 1和手动禁用status == 2的密钥
if status == 3 {
deletedCount++
} else {
remainingKeys = append(remainingKeys, key)
// 保留非自动禁用密钥的状态信息,重新索引
if status != 1 {
newStatusList[newIndex] = status
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
newDisabledTime[newIndex] = t
}
}
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
newDisabledReason[newIndex] = r
}
}
}
newIndex++
}
}
if deletedCount == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "没有需要删除的自动禁用密钥",
})
return
}
// Update channel with remaining keys
channel.Key = strings.Join(remainingKeys, "\n")
channel.ChannelInfo.MultiKeySize = len(remainingKeys)
channel.ChannelInfo.MultiKeyStatusList = newStatusList
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
"data": deletedCount,
})
return
default:
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不支持的操作",
})
return
}
}

View File

@@ -0,0 +1,27 @@
package controller
import (
"net/http"
"one-api/model"
"github.com/gin-gonic/gin"
)
// GetMissingModels returns the list of model names that are referenced by channels
// but do not have corresponding records in the models meta table.
// This helps administrators quickly discover models that need configuration.
func GetMissingModels(c *gin.Context) {
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
}

178
controller/model_meta.go Normal file
View File

@@ -0,0 +1,178 @@
package controller
import (
"encoding/json"
"strconv"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 填充附加字段
for _, m := range modelsMeta {
fillModelExtra(m)
}
var total int64
model.DB.Model(&model.Model{}).Count(&total)
// 统计供应商计数(全部数据,不受分页影响)
vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
}
// SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) {
keyword := c.Query("keyword")
vendor := c.Query("vendor")
pageInfo := common.GetPageQuery(c)
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
for _, m := range modelsMeta {
fillModelExtra(m)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, pageInfo)
}
// GetModelMeta 根据 ID 获取单条模型信息
func GetModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
fillModelExtra(&m)
common.ApiSuccess(c, &m)
}
// CreateModelMeta 新建模型
func CreateModelMeta(c *gin.Context) {
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// UpdateModelMeta 更新模型
func UpdateModelMeta(c *gin.Context) {
statusOnly := c.Query("status_only") == "true"
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
return
}
if statusOnly {
// 只更新状态,防止误清空其他字段
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
common.ApiError(c, err)
return
}
} else {
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Update(); err != nil {
common.ApiError(c, err)
return
}
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// DeleteModelMeta 删除模型
func DeleteModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, nil)
}
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
func fillModelExtra(m *model.Model) {
if m.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(m.ModelName)
if b, err := json.Marshal(eps); err == nil {
m.Endpoints = string(b)
}
}
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
m.BoundChannels = channels
}
// 填充启用分组
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
// 填充计费类型
m.QuotaType = model.GetModelQuotaType(m.ModelName)
}

View File

@@ -5,10 +5,8 @@ import (
"fmt"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/types"
"time"
@@ -32,30 +30,8 @@ func Playground(c *gin.Context) {
return
}
playgroundRequest := &dto.PlayGroundRequest{}
err := common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
if playgroundRequest.Model == "" {
newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
c.Set("original_model", playgroundRequest.Model)
group := playgroundRequest.Group
userGroup := c.GetString("group")
if group == "" {
group = userGroup
} else {
if !setting.GroupInUserUsableGroups(group) && group != userGroup {
newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
c.Set("group", group)
}
group := c.GetString("group")
modelName := c.GetString("original_model")
userId := c.GetInt("id")
@@ -73,7 +49,7 @@ func Playground(c *gin.Context) {
Group: group,
}
_ = middleware.SetupContextForToken(c, tempToken)
_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
_, newAPIError = getChannel(c, group, modelName, 0)
if newAPIError != nil {
return
}

View File

@@ -0,0 +1,90 @@
package controller
import (
"strconv"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
func GetPrefillGroups(c *gin.Context) {
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
}
// CreatePrefillGroup 创建新的预填组
func CreatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// UpdatePrefillGroup 更新预填组
func UpdatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// DeletePrefillGroup 删除预填组
func DeletePrefillGroup(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}

View File

@@ -41,9 +41,11 @@ func GetPricing(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": pricing,
"group_ratio": groupRatio,
"usable_group": usableGroup,
})
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
})
}
func ResetModelRatio(c *gin.Context) {

View File

@@ -47,7 +47,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
err = relay.TextHelper(c)
}
if constant2.ErrorLogEnabled && err != nil {
if constant2.ErrorLogEnabled && err != nil && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
@@ -62,6 +62,14 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
other["admin_info"] = adminInfo
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
}

553
controller/twofa.go Normal file
View File

@@ -0,0 +1,553 @@
package controller
import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// Setup2FARequest 设置2FA请求结构
type Setup2FARequest struct {
Code string `json:"code" binding:"required"`
}
// Verify2FARequest 验证2FA请求结构
type Verify2FARequest struct {
Code string `json:"code" binding:"required"`
}
// Setup2FAResponse 设置2FA响应结构
type Setup2FAResponse struct {
Secret string `json:"secret"`
QRCodeData string `json:"qr_code_data"`
BackupCodes []string `json:"backup_codes"`
}
// Setup2FA 初始化2FA设置
func Setup2FA(c *gin.Context) {
userId := c.GetInt("id")
// 检查用户是否已经启用2FA
existing, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
if existing != nil && existing.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已启用2FA请先禁用后重新设置",
})
return
}
// 如果存在已禁用的2FA记录先删除它
if existing != nil && !existing.IsEnabled {
if err := existing.Delete(); err != nil {
common.ApiError(c, err)
return
}
existing = nil // 重置为nil后续将创建新记录
}
// 获取用户信息
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
// 生成TOTP密钥
key, err := common.GenerateTOTPSecret(user.Username)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "生成2FA密钥失败",
})
common.SysError("生成TOTP密钥失败: " + err.Error())
return
}
// 生成备用码
backupCodes, err := common.GenerateBackupCodes()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
return
}
// 生成二维码数据
qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username)
// 创建或更新2FA记录暂未启用
twoFA := &model.TwoFA{
UserId: userId,
Secret: key.Secret(),
IsEnabled: false,
}
if existing != nil {
// 更新现有记录
twoFA.Id = existing.Id
err = twoFA.Update()
} else {
// 创建新记录
err = twoFA.Create()
}
if err != nil {
common.ApiError(c, err)
return
}
// 创建备用码记录
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "2FA设置初始化成功请使用认证器扫描二维码并输入验证码完成设置",
"data": Setup2FAResponse{
Secret: key.Secret(),
QRCodeData: qrCodeData,
BackupCodes: backupCodes,
},
})
}
// Enable2FA 启用2FA
func Enable2FA(c *gin.Context) {
var req Setup2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
userId := c.GetInt("id")
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
if twoFA == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先完成2FA初始化设置",
})
return
}
if twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "2FA已经启用",
})
return
}
// 验证TOTP验证码
cleanCode, err := common.ValidateNumericCode(req.Code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码或备用码错误,请重试",
})
return
}
// 启用2FA
if err := twoFA.Enable(); err != nil {
common.ApiError(c, err)
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证启用成功",
})
}
// Disable2FA 禁用2FA
func Disable2FA(c *gin.Context) {
var req Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
userId := c.GetInt("id")
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
// 验证TOTP验证码或备用码
cleanCode, err := common.ValidateNumericCode(req.Code)
isValidTOTP := false
isValidBackup := false
if err == nil {
// 尝试验证TOTP
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
}
if !isValidTOTP {
// 尝试验证备用码
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
if !isValidTOTP && !isValidBackup {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码或备用码错误,请重试",
})
return
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
common.ApiError(c, err)
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证已禁用",
})
}
// Get2FAStatus 获取用户2FA状态
func Get2FAStatus(c *gin.Context) {
userId := c.GetInt("id")
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
status := map[string]interface{}{
"enabled": false,
"locked": false,
}
if twoFA != nil {
status["enabled"] = twoFA.IsEnabled
status["locked"] = twoFA.IsLocked()
if twoFA.IsEnabled {
// 获取剩余备用码数量
backupCount, err := model.GetUnusedBackupCodeCount(userId)
if err != nil {
common.SysError("获取备用码数量失败: " + err.Error())
} else {
status["backup_codes_remaining"] = backupCount
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": status,
})
}
// RegenerateBackupCodes 重新生成备用码
func RegenerateBackupCodes(c *gin.Context) {
var req Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
userId := c.GetInt("id")
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
// 验证TOTP验证码
cleanCode, err := common.ValidateNumericCode(req.Code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if !valid {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码或备用码错误,请重试",
})
return
}
// 生成新的备用码
backupCodes, err := common.GenerateBackupCodes()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
return
}
// 保存新的备用码
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "备用码重新生成成功",
"data": map[string]interface{}{
"backup_codes": backupCodes,
},
})
}
// Verify2FALogin 登录时验证2FA
func Verify2FALogin(c *gin.Context) {
var req Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
// 从会话中获取pending用户信息
session := sessions.Default(c)
pendingUserId := session.Get("pending_user_id")
if pendingUserId == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
userId, ok := pendingUserId.(int)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话数据无效,请重新登录",
})
return
}
// 获取用户信息
user, err := model.GetUserById(userId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(user.Id)
if err != nil {
common.ApiError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
// 验证TOTP验证码或备用码
cleanCode, err := common.ValidateNumericCode(req.Code)
isValidTOTP := false
isValidBackup := false
if err == nil {
// 尝试验证TOTP
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
}
if !isValidTOTP {
// 尝试验证备用码
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
if !isValidTOTP && !isValidBackup {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码或备用码错误,请重试",
})
return
}
// 2FA验证成功清理pending会话信息并完成登录
session.Delete("pending_username")
session.Delete("pending_user_id")
session.Save()
setupLogin(user, c)
}
// Admin2FAStats 管理员获取2FA统计信息
func Admin2FAStats(c *gin.Context) {
stats, err := model.GetTwoFAStats()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
// AdminDisable2FA 管理员强制禁用用户2FA
func AdminDisable2FA(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户ID格式错误",
})
return
}
// 检查目标用户权限
targetUser, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权操作同级或更高级用户的2FA设置",
})
return
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
if errors.Is(err, model.ErrTwoFANotEnabled) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
common.ApiError(c, err)
return
}
// 记录操作日志
adminId := c.GetInt("id")
model.RecordLog(userId, model.LogTypeManage,
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "用户2FA已被强制禁用",
})
}

View File

@@ -62,6 +62,32 @@ func Login(c *gin.Context) {
})
return
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
// 设置pending session等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)
session.Set("pending_user_id", user.Id)
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "无法保存会话信息,请重试",
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "请输入两步验证码",
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
},
})
return
}
setupLogin(&user, c)
}

124
controller/vendor_meta.go Normal file
View File

@@ -0,0 +1,124 @@
package controller
import (
"strconv"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// GetAllVendors 获取供应商列表(分页)
func GetAllVendors(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
var total int64
model.DB.Model(&model.Vendor{}).Count(&total)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// SearchVendors 搜索供应商
func SearchVendors(c *gin.Context) {
keyword := c.Query("keyword")
pageInfo := common.GetPageQuery(c)
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// GetVendorMeta 根据 ID 获取供应商
func GetVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
v, err := model.GetVendorByID(id)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, v)
}
// CreateVendorMeta 新建供应商
func CreateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Name == "" {
common.ApiErrorMsg(c, "供应商名称不能为空")
return
}
// 创建前先检查名称
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// UpdateVendorMeta 更新供应商
func UpdateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Id == 0 {
common.ApiErrorMsg(c, "缺少供应商 ID")
return
}
// 名称冲突检查
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// DeleteVendorMeta 删除供应商
func DeleteVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}

View File

@@ -361,7 +361,7 @@ type ClaudeUsage struct {
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
}
type ClaudeServerToolUse struct {

View File

@@ -1,4 +1,4 @@
package gemini
package dto
import (
"encoding/json"
@@ -56,7 +56,7 @@ type FunctionCall struct {
Arguments any `json:"args"`
}
type FunctionResponse struct {
type GeminiFunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
@@ -81,7 +81,7 @@ type GeminiPart struct {
Thought bool `json:"thought,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
FileData *GeminiFileData `json:"fileData,omitempty"`
ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"`
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`

View File

@@ -99,8 +99,11 @@ type StreamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}
func (r *GeneralOpenAIRequest) GetMaxTokens() int {
return int(r.MaxTokens)
func (r *GeneralOpenAIRequest) GetMaxTokens() uint {
if r.MaxCompletionTokens != 0 {
return r.MaxCompletionTokens
}
return r.MaxTokens
}
func (r *GeneralOpenAIRequest) ParseInput() []string {

14
go.mod
View File

@@ -7,9 +7,10 @@ require (
github.com/Calcium-Ion/go-epay v0.0.4
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.26.1
github.com/aws/aws-sdk-go-v2 v1.37.2
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
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/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
@@ -24,6 +25,7 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0
@@ -41,10 +43,10 @@ require (
require (
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect

29
go.sum
View File

@@ -6,20 +6,23 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76wYsSZIZZQYBxkmMEjvL6GHy8XU=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg=
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
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/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
@@ -169,6 +172,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=

View File

@@ -4,7 +4,10 @@ import (
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"strconv"
"strings"
@@ -234,6 +237,16 @@ func TokenAuth() func(c *gin.Context) {
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
return
}
allowIpsMap := token.GetIpLimitsMap()
if len(allowIpsMap) != 0 {
clientIp := c.ClientIP()
if _, ok := allowIpsMap[clientIp]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
return
}
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
@@ -247,6 +260,25 @@ func TokenAuth() func(c *gin.Context) {
userCache.WriteContext(c)
userGroup := userCache.Group
tokenGroup := token.Group
if tokenGroup != "" {
// check common.UserUsableGroups[userGroup]
if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
return
}
// check group in common.GroupRatio
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
}
}
userGroup = tokenGroup
}
common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
err = SetupContextForToken(c, token, parts...)
if err != nil {
return
@@ -273,7 +305,6 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
} else {
c.Set("token_model_limit_enabled", false)
}
c.Set("allow_ips", token.GetIpLimitsMap())
c.Set("token_group", token.Group)
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {

View File

@@ -27,14 +27,6 @@ type ModelRequest struct {
func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
allowIpsMap := common.GetContextKeyStringMap(c, constant.ContextKeyTokenAllowIps)
if len(allowIpsMap) != 0 {
clientIp := c.ClientIP()
if _, ok := allowIpsMap[clientIp]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
return
}
}
var channel *model.Channel
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
modelRequest, shouldSelectChannel, err := getModelRequest(c)
@@ -42,24 +34,6 @@ func Distribute() func(c *gin.Context) {
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request, "+err.Error())
return
}
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
if tokenGroup != "" {
// check common.UserUsableGroups[userGroup]
if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
return
}
// check group in common.GroupRatio
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
}
}
userGroup = tokenGroup
}
common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
@@ -81,22 +55,21 @@ func Distribute() func(c *gin.Context) {
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
var tokenModelLimit map[string]bool
if ok {
tokenModelLimit = s.(map[string]bool)
} else {
tokenModelLimit = map[string]bool{}
}
if tokenModelLimit != nil {
if _, ok := tokenModelLimit[modelRequest.Model]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model)
return
}
} else {
if !ok {
// token model limit is empty, all models are not allowed
abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型")
return
}
var tokenModelLimit map[string]bool
tokenModelLimit, ok = s.(map[string]bool)
if !ok {
tokenModelLimit = map[string]bool{}
}
matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
if _, ok := tokenModelLimit[matchName]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model)
return
}
}
if shouldSelectChannel {
@@ -105,6 +78,23 @@ func Distribute() func(c *gin.Context) {
return
}
var selectGroup string
userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
// check path is /pg/chat/completions
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
playgroundRequest := &dto.PlayGroundRequest{}
err = common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error())
return
}
if playgroundRequest.Group != "" {
if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup {
abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组")
return
}
userGroup = playgroundRequest.Group
}
}
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
if err != nil {
showGroup := userGroup
@@ -269,6 +259,9 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
if channel.ChannelInfo.IsMultiKey {
common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true)
common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index)
} else {
// 必须设置为 false否则在重试到单个 key 的时候会导致日志显示错误
common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false)
}
// c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
common.SetContextKey(c, constant.ContextKeyChannelKey, key)

View File

@@ -284,6 +284,21 @@ func FixAbility() (int, int, error) {
return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试")
}
defer fixLock.Unlock()
// truncate abilities table
if common.UsingSQLite {
err := DB.Exec("DELETE FROM abilities").Error
if err != nil {
common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
return 0, 0, err
}
} else {
err := DB.Exec("TRUNCATE TABLE abilities").Error
if err != nil {
common.SysError(fmt.Sprintf("Truncate abilities failed: %s", err.Error()))
return 0, 0, err
}
}
var channels []*Channel
// Find all channels
err := DB.Model(&Channel{}).Find(&channels).Error

View File

@@ -41,19 +41,25 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Settings string `json:"settings"`
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
// add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
// cache info
Keys []string `json:"-" gorm:"-"`
}
type ChannelInfo struct {
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表key index -> status
MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表key index -> status
MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表key index -> reason
MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表key index -> time
MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
}
// Value implements driver.Valuer interface
@@ -67,10 +73,13 @@ func (c *ChannelInfo) Scan(value interface{}) error {
return common.Unmarshal(bytesValue, c)
}
func (channel *Channel) getKeys() []string {
func (channel *Channel) GetKeys() []string {
if channel.Key == "" {
return []string{}
}
if len(channel.Keys) > 0 {
return channel.Keys
}
trimmed := strings.TrimSpace(channel.Key)
// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
if strings.HasPrefix(trimmed, "[") {
@@ -95,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
}
// Obtain all keys (split by \n)
keys := channel.getKeys()
keys := channel.GetKeys()
if len(keys) == 0 {
// No keys available, return error, should disable the channel
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
@@ -132,7 +141,7 @@ 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 := GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
@@ -491,8 +500,8 @@ var channelStatusLock sync.Mutex
// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling
var channelPollingLocks sync.Map
// getChannelPollingLock returns or creates a mutex for the given channel ID
func getChannelPollingLock(channelId int) *sync.Mutex {
// GetChannelPollingLock returns or creates a mutex for the given channel ID
func GetChannelPollingLock(channelId int) *sync.Mutex {
if lock, exists := channelPollingLocks.Load(channelId); exists {
return lock.(*sync.Mutex)
}
@@ -522,8 +531,8 @@ func CleanupChannelPollingLocks() {
})
}
func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
keys := channel.getKeys()
func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) {
keys := channel.GetKeys()
if len(keys) == 0 {
channel.Status = status
} else {
@@ -541,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
} else {
channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
if channel.ChannelInfo.MultiKeyDisabledReason == nil {
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
}
if channel.ChannelInfo.MultiKeyDisabledTime == nil {
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
}
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
}
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
channel.Status = common.ChannelStatusAutoDisabled
@@ -563,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
}
if channelCache.ChannelInfo.IsMultiKey {
// 如果是多Key模式更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status)
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
//CacheUpdateChannel(channelCache)
//return true
} else {
@@ -594,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status
handlerMultiKeyUpdate(channel, usingKey, status)
handlerMultiKeyUpdate(channel, usingKey, status, reason)
if beforeStatus != channel.Status {
shouldUpdateAbilities = true
}

View File

@@ -5,7 +5,9 @@ import (
"fmt"
"math/rand"
"one-api/common"
"one-api/constant"
"one-api/setting"
"one-api/setting/ratio_setting"
"sort"
"strings"
"sync"
@@ -66,6 +68,20 @@ func InitChannelCache() {
channelSyncLock.Lock()
group2model2channels = newGroup2model2channels
//channelsIDM = newChannelId2channel
for i, channel := range newChannelId2channel {
if channel.ChannelInfo.IsMultiKey {
channel.Keys = channel.GetKeys()
if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
if oldChannel, ok := channelsIDM[i]; ok {
// 存在旧的渠道如果是多key且轮询保留轮询索引信息
if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex
}
}
}
}
}
channelsIDM = newChannelId2channel
channelSyncLock.Unlock()
common.SysLog("channels synced from database")
@@ -113,13 +129,6 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
}
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
if strings.HasPrefix(model, "gpt-4-gizmo") {
model = "gpt-4-gizmo-*"
}
if strings.HasPrefix(model, "gpt-4o-gizmo") {
model = "gpt-4o-gizmo-*"
}
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
return GetRandomSatisfiedChannel(group, model, retry)
@@ -127,8 +136,16 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
// First, try to find channels with the exact model name.
channels := group2model2channels[group][model]
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
channels = group2model2channels[group][normalizedModel]
}
if len(channels) == 0 {
return nil, nil
}
@@ -203,9 +220,6 @@ func CacheGetChannel(id int) (*Channel, error) {
if !ok {
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return c, nil
}
@@ -224,9 +238,6 @@ func CacheGetChannelInfo(id int) (*ChannelInfo, error) {
if !ok {
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return &c.ChannelInfo, nil
}

View File

@@ -250,7 +250,12 @@ func migrateDB() error {
&TopUp{},
&QuotaData{},
&Task{},
&Model{},
&Vendor{},
&PrefillGroup{},
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
)
if err != nil {
return err
@@ -276,7 +281,12 @@ func migrateDBFast() error {
{&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"},
}
// 动态计算migration数量确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))

30
model/missing_models.go Normal file
View File

@@ -0,0 +1,30 @@
package model
// GetMissingModels returns model names that are referenced in the system
func GetMissingModels() ([]string, error) {
// 1. 获取所有已启用模型(去重)
models := GetEnabledModels()
if len(models) == 0 {
return []string{}, nil
}
// 2. 查询已有的元数据模型名
var existing []string
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
return nil, err
}
existingSet := make(map[string]struct{}, len(existing))
for _, e := range existing {
existingSet[e] = struct{}{}
}
// 3. 收集缺失模型
var missing []string
for _, name := range models {
if _, ok := existingSet[name]; !ok {
missing = append(missing, name)
}
}
return missing, nil
}

34
model/model_extra.go Normal file
View File

@@ -0,0 +1,34 @@
package model
// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
// 使用在 updatePricing() 中维护的缓存映射O(1) 读取,适合高并发场景。
func GetModelEnableGroups(modelName string) []string {
// 确保缓存最新
GetPricing()
if modelName == "" {
return make([]string, 0)
}
modelEnableGroupsLock.RLock()
groups, ok := modelEnableGroups[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return make([]string, 0)
}
return groups
}
// GetModelQuotaType 返回指定模型的计费类型quota_type
// 同样使用缓存映射,避免每次遍历定价切片。
func GetModelQuotaType(modelName string) int {
GetPricing()
modelEnableGroupsLock.RLock()
quota, ok := modelQuotaTypeMap[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return 0
}
return quota
}

204
model/model_meta.go Normal file
View File

@@ -0,0 +1,204 @@
package model
import (
"one-api/common"
"strconv"
"strings"
"gorm.io/gorm"
)
// Model 用于存储模型的元数据,例如描述、标签等
// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
// Status: 1 表示启用0 表示禁用,保留以便后续功能扩展
// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
//
// 该表设计遵循第三范式3NF
// 1. 每一列都与主键Id 或 ModelName直接相关
// 2. 不存在部分依赖ModelName 是唯一键)
// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName而非依赖于其他非主键列
// 这样既保证了数据一致性,也方便后期扩展
// 模型名称匹配规则
const (
NameRuleExact = iota // 0 精确匹配
NameRulePrefix // 1 前缀匹配
NameRuleContains // 2 包含匹配
NameRuleSuffix // 3 后缀匹配
)
type BoundChannel struct {
Name string `json:"name"`
Type int `json:"type"`
}
type Model struct {
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
Description string `json:"description,omitempty" gorm:"type:text"`
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"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
QuotaType int `json:"quota_type" gorm:"-"`
NameRule int `json:"name_rule" gorm:"default:0"`
}
// Insert 创建新的模型元数据记录
func (mi *Model) Insert() error {
now := common.GetTimestamp()
mi.CreatedTime = now
mi.UpdatedTime = now
return DB.Create(mi).Error
}
// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID
func IsModelNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新现有模型记录
func (mi *Model) Update() error {
// 仅更新需要变更的字段,避免覆盖 CreatedTime
mi.UpdatedTime = common.GetTimestamp()
// 排除 created_time其余字段自动更新避免新增字段时需要维护列表
return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error
}
// Delete 软删除模型记录
func (mi *Model) Delete() error {
return DB.Delete(mi).Error
}
// GetModelByName 根据模型名称查询元数据
func GetModelByName(name string) (*Model, error) {
var mi Model
err := DB.Where("model_name = ?", name).First(&mi).Error
if err != nil {
return nil, err
}
return &mi, nil
}
// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响)
func GetVendorModelCounts() (map[int64]int64, error) {
var stats []struct {
VendorID int64
Count int64
}
if err := DB.Model(&Model{}).
Select("vendor_id as vendor_id, count(*) as count").
Group("vendor_id").
Scan(&stats).Error; err != nil {
return nil, err
}
m := make(map[int64]int64, len(stats))
for _, s := range stats {
m[s.VendorID] = s.Count
}
return m, nil
}
// GetAllModels 分页获取所有模型元数据
func GetAllModels(offset int, limit int) ([]*Model, error) {
var models []*Model
err := DB.Offset(offset).Limit(limit).Find(&models).Error
return models, err
}
// GetBoundChannels 查询支持该模型的渠道(名称+类型)
func GetBoundChannels(modelName string) ([]BoundChannel, error) {
var channels []BoundChannel
err := DB.Table("channels").
Select("channels.name, channels.type").
Joins("join abilities on abilities.channel_id = channels.id").
Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
Group("channels.id").
Scan(&channels).Error
return channels, err
}
// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含
func FindModelByNameWithRule(name string) (*Model, error) {
// 1. 精确匹配
if m, err := GetModelByName(name); err == nil {
return m, nil
}
// 2. 规则匹配
var models []*Model
if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
return nil, err
}
var prefixMatch, suffixMatch, containsMatch *Model
for _, m := range models {
switch m.NameRule {
case NameRulePrefix:
if strings.HasPrefix(name, m.ModelName) {
if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
prefixMatch = m
}
}
case NameRuleSuffix:
if strings.HasSuffix(name, m.ModelName) {
if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
suffixMatch = m
}
}
case NameRuleContains:
if strings.Contains(name, m.ModelName) {
if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
containsMatch = m
}
}
}
}
if prefixMatch != nil {
return prefixMatch, nil
}
if suffixMatch != nil {
return suffixMatch, nil
}
if containsMatch != nil {
return containsMatch, nil
}
return nil, gorm.ErrRecordNotFound
}
// SearchModels 根据关键词和供应商搜索模型,支持分页
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
var models []*Model
db := DB.Model(&Model{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
}
if vendor != "" {
// 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
if vid, err := strconv.Atoi(vendor); err == nil {
db = db.Where("models.vendor_id = ?", vid)
} else {
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
}
}
var total int64
err := db.Count(&total).Error
if err != nil {
return nil, 0, err
}
err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
return models, total, err
}

126
model/prefill_group.go Normal file
View File

@@ -0,0 +1,126 @@
package model
import (
"encoding/json"
"database/sql/driver"
"one-api/common"
"gorm.io/gorm"
)
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
// Name 字段保持唯一,用于在前端下拉框中展示。
// Type 字段用于区分组的类别可选值如model、tag、endpoint。
// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例:
// ["gpt-4o", "gpt-3.5-turbo"]
// 设计遵循 3NF避免冗余提供灵活扩展能力。
// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取
type JSONValue json.RawMessage
// Value 实现 driver.Valuer 接口,用于数据库写入
func (j JSONValue) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return []byte(j), nil
}
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
func (j *JSONValue) Scan(value interface{}) error {
switch v := value.(type) {
case nil:
*j = nil
return nil
case []byte:
// 拷贝底层字节,避免保留底层缓冲区
b := make([]byte, len(v))
copy(b, v)
*j = JSONValue(b)
return nil
case string:
*j = JSONValue([]byte(v))
return nil
default:
// 其他类型尝试序列化为 JSON
b, err := json.Marshal(v)
if err != nil {
return err
}
*j = JSONValue(b)
return nil
}
}
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
func (j JSONValue) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
return j, nil
}
// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
func (j *JSONValue) UnmarshalJSON(data []byte) error {
if data == nil {
*j = nil
return nil
}
b := make([]byte, len(data))
copy(b, data)
*j = JSONValue(b)
return nil
}
type PrefillGroup struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
Type string `json:"type" gorm:"size:32;index;not null"`
Items JSONValue `json:"items" gorm:"type:json"`
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// Insert 新建组
func (g *PrefillGroup) Insert() error {
now := common.GetTimestamp()
g.CreatedTime = now
g.UpdatedTime = now
return DB.Create(g).Error
}
// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID
func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新组
func (g *PrefillGroup) Update() error {
g.UpdatedTime = common.GetTimestamp()
return DB.Save(g).Error
}
// DeleteByID 根据 ID 删除组
func DeletePrefillGroupByID(id int) error {
return DB.Delete(&PrefillGroup{}, id).Error
}
// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
var groups []*PrefillGroup
query := DB.Model(&PrefillGroup{})
if groupType != "" {
query = query.Where("type = ?", groupType)
}
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
}

View File

@@ -1,30 +1,50 @@
package model
import (
"fmt"
"one-api/common"
"one-api/constant"
"one-api/setting/ratio_setting"
"one-api/types"
"sync"
"time"
"encoding/json"
"fmt"
"strings"
"one-api/common"
"one-api/constant"
"one-api/setting/ratio_setting"
"one-api/types"
"sync"
"time"
)
type Pricing struct {
ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
}
type PricingVendor struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
}
var (
pricingMap []Pricing
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
// 缓存映射:模型名 -> 启用分组 / 计费类型
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
modelEnableGroupsLock = sync.RWMutex{}
)
var (
@@ -46,6 +66,15 @@ func GetPricing() []Pricing {
return pricingMap
}
// GetVendors 返回当前定价接口使用到的供应商信息
func GetVendors() []PricingVendor {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
// 保证先刷新一次
GetPricing()
}
return vendorsList
}
func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
if model == "" {
return make([]constant.EndpointType, 0)
@@ -65,6 +94,77 @@ func updatePricing() {
common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
return
}
// 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model
_ = DB.Find(&allMeta).Error
metaMap := make(map[string]*Model)
prefixList := make([]*Model, 0)
suffixList := make([]*Model, 0)
containsList := make([]*Model, 0)
for i := range allMeta {
m := &allMeta[i]
if m.NameRule == NameRuleExact {
metaMap[m.ModelName] = m
} else {
switch m.NameRule {
case NameRulePrefix:
prefixList = append(prefixList, m)
case NameRuleSuffix:
suffixList = append(suffixList, m)
case NameRuleContains:
containsList = append(containsList, m)
}
}
}
// 将非精确规则模型匹配到 metaMap
for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range suffixList {
for _, pricingModel := range enableAbilities {
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range containsList {
for _, pricingModel := range enableAbilities {
if strings.Contains(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
// 预加载供应商
var vendors []Vendor
_ = DB.Find(&vendors).Error
vendorMap := make(map[int]*Vendor)
for i := range vendors {
vendorMap[vendors[i].Id] = &vendors[i]
}
// 构建对前端友好的供应商列表
vendorsList = make([]PricingVendor, 0, len(vendors))
for _, v := range vendors {
vendorsList = append(vendorsList, PricingVendor{
ID: v.Id,
Name: v.Name,
Description: v.Description,
Icon: v.Icon,
})
}
modelGroupsMap := make(map[string]*types.Set[string])
for _, ability := range enableAbilities {
@@ -79,20 +179,34 @@ func updatePricing() {
//这里使用切片而不是Set因为一个模型可能支持多个端点类型并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
for _, ability := range enableAbilities {
endpoints, ok := modelSupportEndpointsStr[ability.Model]
if !ok {
endpoints = make([]string, 0)
modelSupportEndpointsStr[ability.Model] = endpoints
}
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 再补充模型自定义端点
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := modelSupportEndpointsStr[modelName]
for k := range raw {
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
modelSupportEndpointsStr[modelName] = endpoints
}
}
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr {
@@ -102,26 +216,92 @@ func updatePricing() {
supportedEndpoints = append(supportedEndpoints, endpointType)
}
modelSupportEndpointTypes[model] = supportedEndpoints
}
}
pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricingMap = append(pricingMap, pricing)
}
lastGetPricingTime = time.Now()
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
if _, exists := supportedEndpointMap[string(et)]; !exists {
supportedEndpointMap[string(et)] = info
}
}
}
}
// 2. 自定义端点models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
case map[string]interface{}:
ep := common.EndpointInfo{Method: "POST"}
if p, ok := val["path"].(string); ok {
ep.Path = p
}
if m, ok := val["method"].(string); ok {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}
pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
if meta.Status != 1 {
continue
}
pricing.Description = meta.Description
pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricingMap = append(pricingMap, pricing)
}
// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
}
modelEnableGroupsLock.Unlock()
lastGetPricingTime = time.Now()
}
// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}

14
model/pricing_refresh.go Normal file
View File

@@ -0,0 +1,14 @@
package model
// RefreshPricing 强制立即重新计算与定价相关的缓存。
// 该方法用于需要最新数据的内部管理 API
// 因此会绕过默认的 1 分钟延迟刷新。
func RefreshPricing() {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
}

322
model/twofa.go Normal file
View File

@@ -0,0 +1,322 @@
package model
import (
"errors"
"fmt"
"one-api/common"
"time"
"gorm.io/gorm"
)
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
// TwoFA 用户2FA设置表
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"`
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
LockedUntil *time.Time `json:"locked_until,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// TwoFABackupCode 备用码使用记录表
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"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// GetTwoFAByUserId 根据用户ID获取2FA设置
func GetTwoFAByUserId(userId int) (*TwoFA, error) {
if userId == 0 {
return nil, errors.New("用户ID不能为空")
}
var twoFA TwoFA
err := DB.Where("user_id = ?", userId).First(&twoFA).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 返回nil表示未设置2FA
}
return nil, err
}
return &twoFA, nil
}
// IsTwoFAEnabled 检查用户是否启用了2FA
func IsTwoFAEnabled(userId int) bool {
twoFA, err := GetTwoFAByUserId(userId)
if err != nil || twoFA == nil {
return false
}
return twoFA.IsEnabled
}
// CreateTwoFA 创建2FA设置
func (t *TwoFA) Create() error {
// 检查用户是否已存在2FA设置
existing, err := GetTwoFAByUserId(t.UserId)
if err != nil {
return err
}
if existing != nil {
return errors.New("用户已存在2FA设置")
}
// 验证用户存在
var user User
if err := DB.First(&user, t.UserId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("用户不存在")
}
return err
}
return DB.Create(t).Error
}
// Update 更新2FA设置
func (t *TwoFA) Update() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
return DB.Save(t).Error
}
// Delete 删除2FA设置
func (t *TwoFA) Delete() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
// 使用事务确保原子性
return DB.Transaction(func(tx *gorm.DB) error {
// 同时删除相关的备用码记录(硬删除)
if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
// 硬删除2FA记录
return tx.Unscoped().Delete(t).Error
})
}
// ResetFailedAttempts 重置失败尝试次数
func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
}
// IncrementFailedAttempts 增加失败尝试次数
func (t *TwoFA) IncrementFailedAttempts() error {
t.FailedAttempts++
// 检查是否需要锁定
if t.FailedAttempts >= common.MaxFailAttempts {
lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second)
t.LockedUntil = &lockUntil
}
return t.Update()
}
// IsLocked 检查账户是否被锁定
func (t *TwoFA) IsLocked() bool {
if t.LockedUntil == nil {
return false
}
return time.Now().Before(*t.LockedUntil)
}
// CreateBackupCodes 创建备用码
func CreateBackupCodes(userId int, codes []string) error {
return DB.Transaction(func(tx *gorm.DB) error {
// 先删除现有的备用码
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
// 创建新的备用码记录
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
backupCode := TwoFABackupCode{
UserId: userId,
CodeHash: hashedCode,
IsUsed: false,
}
if err := tx.Create(&backupCode).Error; err != nil {
return err
}
}
return nil
})
}
// ValidateBackupCode 验证并使用备用码
func ValidateBackupCode(userId int, code string) (bool, error) {
if !common.ValidateBackupCode(code) {
return false, errors.New("验证码或备用码不正确")
}
normalizedCode := common.NormalizeBackupCode(code)
// 查找未使用的备用码
var backupCodes []TwoFABackupCode
if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil {
return false, err
}
// 验证备用码
for _, bc := range backupCodes {
if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) {
// 标记为已使用
now := time.Now()
bc.IsUsed = true
bc.UsedAt = &now
if err := DB.Save(&bc).Error; err != nil {
return false, err
}
return true, nil
}
}
return false, nil
}
// GetUnusedBackupCodeCount 获取未使用的备用码数量
func GetUnusedBackupCodeCount(userId int) (int, error) {
var count int64
err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error
return int(count), err
}
// DisableTwoFA 禁用用户的2FA
func DisableTwoFA(userId int) error {
twoFA, err := GetTwoFAByUserId(userId)
if err != nil {
return err
}
if twoFA == nil {
return ErrTwoFANotEnabled
}
// 删除2FA设置和备用码
return twoFA.Delete()
}
// EnableTwoFA 启用2FA
func (t *TwoFA) Enable() error {
t.IsEnabled = true
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
}
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
// 检查是否被锁定
if t.IsLocked() {
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
}
// 验证TOTP码
if !common.ValidateTOTPCode(t.Secret, code) {
// 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil {
common.SysError("更新2FA失败次数失败: " + err.Error())
}
return false, nil
}
// 验证成功,重置失败次数并更新最后使用时间
now := time.Now()
t.FailedAttempts = 0
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
common.SysError("更新2FA使用记录失败: " + err.Error())
}
return true, nil
}
// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录
func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
// 检查是否被锁定
if t.IsLocked() {
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
}
// 验证备用码
valid, err := ValidateBackupCode(t.UserId, code)
if err != nil {
return false, err
}
if !valid {
// 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil {
common.SysError("更新2FA失败次数失败: " + err.Error())
}
return false, nil
}
// 验证成功,重置失败次数并更新最后使用时间
now := time.Now()
t.FailedAttempts = 0
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
common.SysError("更新2FA使用记录失败: " + err.Error())
}
return true, nil
}
// GetTwoFAStats 获取2FA统计信息管理员使用
func GetTwoFAStats() (map[string]interface{}, error) {
var totalUsers, enabledUsers int64
// 总用户数
if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil {
return nil, err
}
// 启用2FA的用户数
if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil {
return nil, err
}
enabledRate := float64(0)
if totalUsers > 0 {
enabledRate = float64(enabledUsers) / float64(totalUsers) * 100
}
return map[string]interface{}{
"total_users": totalUsers,
"enabled_users": enabledUsers,
"enabled_rate": fmt.Sprintf("%.1f%%", enabledRate),
}, nil
}

88
model/vendor_meta.go Normal file
View File

@@ -0,0 +1,88 @@
package model
import (
"one-api/common"
"gorm.io/gorm"
)
// Vendor 用于存储供应商信息,供模型引用
// Name 唯一,用于在模型中关联
// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染
// Status 预留字段1 表示启用
// 本表同样遵循 3NF 设计范式
type Vendor struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
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"`
}
// Insert 创建新的供应商记录
func (v *Vendor) Insert() error {
now := common.GetTimestamp()
v.CreatedTime = now
v.UpdatedTime = now
return DB.Create(v).Error
}
// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID
func IsVendorNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新供应商记录
func (v *Vendor) Update() error {
v.UpdatedTime = common.GetTimestamp()
return DB.Save(v).Error
}
// Delete 软删除供应商
func (v *Vendor) Delete() error {
return DB.Delete(v).Error
}
// GetVendorByID 根据 ID 获取供应商
func GetVendorByID(id int) (*Vendor, error) {
var v Vendor
err := DB.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
}
// GetAllVendors 获取全部供应商(分页)
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
var vendors []*Vendor
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
return vendors, err
}
// SearchVendors 按关键字搜索供应商
func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) {
db := DB.Model(&Vendor{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var vendors []*Vendor
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
return nil, 0, err
}
return vendors, total, nil
}

View File

@@ -26,6 +26,7 @@ type Adaptor interface {
GetModelList() []string
GetChannelName() string
ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error)
ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error)
}
type TaskAdaptor interface {

View File

@@ -3,25 +3,29 @@ package ali
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"github.com/gin-gonic/gin"
"strings"
)
type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
panic("implement me")
return nil, nil
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
return req, nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -29,18 +33,24 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
var fullRequestURL string
switch info.RelayMode {
case constant.RelayModeEmbeddings:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.BaseUrl)
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.BaseUrl)
switch info.RelayFormat {
case relaycommon.RelayFormatClaude:
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.BaseUrl)
default:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.BaseUrl)
switch info.RelayMode {
case constant.RelayModeEmbeddings:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.BaseUrl)
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.BaseUrl)
default:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.BaseUrl)
}
}
return fullRequestURL, nil
}
@@ -60,7 +70,13 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
// docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216
// fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True.
if strings.Contains(request.Model, "thinking") {
request.EnableThinking = true
request.Stream = true
info.IsStream = true
}
// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
if !info.IsStream {
request.EnableThinking = false
@@ -101,18 +117,27 @@ 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) {
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeEmbeddings:
err, usage = aliEmbeddingHandler(c, resp)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
switch info.RelayFormat {
case relaycommon.RelayFormatClaude:
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
default:
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeEmbeddings:
err, usage = aliEmbeddingHandler(c, resp)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
}
}
}
return

View File

@@ -22,6 +22,11 @@ type Adaptor struct {
RequestMode int
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
c.Set("request_model", request.Model)
c.Set("converted_request", request)

View File

@@ -13,6 +13,7 @@ var awsModelIDMap = map[string]string{
"claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0",
"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",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -54,6 +55,9 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"anthropic.claude-opus-4-20250514-v1:0": {
"us": true,
},
"anthropic.claude-opus-4-1-20250805-v1:0": {
"us": true,
},
}
var awsRegionCrossModelPrefixMap = map[string]string{

View File

@@ -19,20 +19,31 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
"github.com/aws/smithy-go/auth/bearer"
)
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
awsSecret := strings.Split(info.ApiKey, "|")
if len(awsSecret) != 3 {
var client *bedrockruntime.Client
switch len(awsSecret) {
case 2:
apiKey := awsSecret[0]
region := awsSecret[1]
client = bedrockruntime.New(bedrockruntime.Options{
Region: region,
BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}},
})
case 3:
ak := awsSecret[0]
sk := awsSecret[1]
region := awsSecret[2]
client = bedrockruntime.New(bedrockruntime.Options{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")),
})
default:
return nil, errors.New("invalid aws secret key")
}
ak := awsSecret[0]
sk := awsSecret[1]
region := awsSecret[2]
client := bedrockruntime.New(bedrockruntime.Options{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")),
})
return client, nil
}

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -34,9 +34,9 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest {
EnableCitation: false,
UserId: request.User,
}
if request.MaxTokens != 0 {
maxTokens := int(request.MaxTokens)
if request.MaxTokens == 1 {
if request.GetMaxTokens() != 0 {
maxTokens := int(request.GetMaxTokens())
if request.GetMaxTokens() == 1 {
maxTokens = 2
}
baiduRequest.MaxOutputTokens = &maxTokens

View File

@@ -9,6 +9,7 @@ import (
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
@@ -18,10 +19,14 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
panic("implement me")
return nil, nil
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
@@ -38,20 +43,33 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/v2/embeddings", info.BaseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v2/images/generations", info.BaseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/v2/images/edits", info.BaseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/v2/rerank", info.BaseUrl), nil
default:
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
keyParts := strings.Split(info.ApiKey, "|")
keyParts := strings.Split(info.ApiKey, "|")
if len(keyParts) == 0 || keyParts[0] == "" {
return errors.New("invalid API key: authorization token is required")
}
if len(keyParts) > 1 {
if keyParts[1] != "" {
req.Set("appid", keyParts[1])
}
}
return errors.New("invalid API key: authorization token is required")
}
if len(keyParts) > 1 {
if keyParts[1] != "" {
req.Set("appid", keyParts[1])
}
}
req.Set("Authorization", "Bearer "+keyParts[0])
return nil
}
@@ -94,11 +112,8 @@ 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)
}
adaptor := openai.Adaptor{}
usage, err = adaptor.DoResponse(c, resp, info)
return
}

View File

@@ -24,6 +24,11 @@ type Adaptor struct {
RequestMode int
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
return request, nil
}
@@ -99,7 +104,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.IsStream {
err, usage = ClaudeStreamHandler(c, resp, info, a.RequestMode)
} else {
err, usage = ClaudeHandler(c, resp, a.RequestMode, info)
err, usage = ClaudeHandler(c, resp, info, a.RequestMode)
}
return
}

View File

@@ -17,6 +17,8 @@ var ModelList = []string{
"claude-sonnet-4-20250514-thinking",
"claude-opus-4-20250514",
"claude-opus-4-20250514-thinking",
"claude-opus-4-1-20250805",
"claude-opus-4-1-20250805-thinking",
}
var ChannelName = "claude"

View File

@@ -149,7 +149,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
MaxTokens: textRequest.MaxTokens,
MaxTokens: textRequest.GetMaxTokens(),
StopSequences: nil,
Temperature: textRequest.Temperature,
TopP: textRequest.TopP,
@@ -740,7 +740,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
defer common.CloseResponseBodyGracefully(resp)
claudeInfo := &ClaudeResponseInfo{

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -5,7 +5,7 @@ import "one-api/dto"
type CfRequest struct {
Messages []dto.Message `json:"messages,omitempty"`
Lora string `json:"lora,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxTokens uint `json:"max_tokens,omitempty"`
Prompt string `json:"prompt,omitempty"`
Raw bool `json:"raw,omitempty"`
Stream bool `json:"stream,omitempty"`

View File

@@ -17,6 +17,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -7,7 +7,7 @@ type CohereRequest struct {
ChatHistory []ChatHistory `json:"chat_history"`
Message string `json:"message"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
MaxTokens uint `json:"max_tokens"`
SafetyMode string `json:"safety_mode,omitempty"`
}

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *common.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
// ConvertAudioRequest implements channel.Adaptor.
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *common.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not implemented")

View File

@@ -19,10 +19,14 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
panic("implement me")
return nil, nil
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {

View File

@@ -24,6 +24,11 @@ type Adaptor struct {
BotType int
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -20,6 +20,26 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if len(request.Contents) > 0 {
for i, content := range request.Contents {
if i == 0 {
if request.Contents[0].Role == "" {
request.Contents[0].Role = "user"
}
}
for _, part := range content.Parts {
if part.FileData != nil {
if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") {
part.FileData.MimeType = "video/webm"
}
}
}
}
}
return request, nil
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req)
@@ -51,13 +71,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
// build gemini imagen request
geminiRequest := GeminiImageRequest{
Instances: []GeminiImageInstance{
geminiRequest := dto.GeminiImageRequest{
Instances: []dto.GeminiImageInstance{
{
Prompt: request.Prompt,
},
},
Parameters: GeminiImageParameters{
Parameters: dto.GeminiImageParameters{
SampleCount: request.N,
AspectRatio: aspectRatio,
PersonGeneration: "allow_adult", // default allow adult
@@ -100,6 +120,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
action := "generateContent"
if info.IsStream {
action = "streamGenerateContent?alt=sse"
if info.RelayMode == constant.RelayModeGemini {
info.DisablePing = true
}
}
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
}
@@ -138,9 +161,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
// only process the first input
geminiRequest := GeminiEmbeddingRequest{
Content: GeminiChatContent{
Parts: []GeminiPart{
geminiRequest := dto.GeminiEmbeddingRequest{
Content: dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{
Text: inputs[0],
},
@@ -173,7 +196,6 @@ 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.RelayMode == constant.RelayModeGemini {
if info.IsStream {
info.DisablePing = true
return GeminiTextGenerationStreamHandler(c, info, resp)
} else {
return GeminiTextGenerationHandler(c, info, resp)

View File

@@ -1,6 +1,7 @@
package gemini
import (
"github.com/pkg/errors"
"io"
"net/http"
"one-api/common"
@@ -28,7 +29,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
}
// 解析为 Gemini 原生响应格式
var geminiResponse GeminiChatResponse
var geminiResponse dto.GeminiChatResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
@@ -71,7 +72,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
var geminiResponse GeminiChatResponse
var geminiResponse dto.GeminiChatResponse
err := common.UnmarshalJsonStr(data, &geminiResponse)
if err != nil {
common.LogError(c, "error unmarshalling stream response: "+err.Error())
@@ -110,10 +111,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
if err != nil {
common.LogError(c, err.Error())
}
info.SendResponseCount++
return true
})
if info.SendResponseCount == 0 {
return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
}
if imageCount != 0 {
if usage.CompletionTokens == 0 {
usage.CompletionTokens = imageCount * 258

View File

@@ -49,12 +49,20 @@ const (
flash25LiteMaxBudget = 24576
)
// clampThinkingBudget 根据模型名称将预算限制在允许的范围内
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
func isNew25ProModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
}
func is25FlashLiteModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
}
// clampThinkingBudget 根据模型名称将预算限制在允许的范围内
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
if is25FlashLite {
if budget < flash25LiteMinBudget {
@@ -81,7 +89,34 @@ func clampThinkingBudget(modelName string, budget int) int {
return budget
}
func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) {
// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens)
// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens)
// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens)
func clampThinkingBudgetByEffort(modelName string, effort string) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
maxBudget := 0
if is25FlashLite {
maxBudget = flash25LiteMaxBudget
}
if isNew25Pro {
maxBudget = pro25MaxBudget
} else {
maxBudget = flash25MaxBudget
}
switch effort {
case "high":
maxBudget = maxBudget * 80 / 100
case "medium":
maxBudget = maxBudget * 50 / 100
case "low":
maxBudget = maxBudget * 20 / 100
}
return clampThinkingBudget(modelName, maxBudget)
}
func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
modelName := info.UpstreamModelName
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
@@ -93,7 +128,7 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn
if len(parts) == 2 && parts[1] != "" {
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
@@ -113,22 +148,27 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn
}
if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
if geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget)
} else {
if len(oaiRequest) > 0 {
// 如果有reasoningEffort参数则根据其值设置思考预算
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
}
}
}
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
}
@@ -137,14 +177,14 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn
}
// Setting safety to the lowest possible values since Gemini is already powerless enough
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
geminiRequest := GeminiChatRequest{
Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: GeminiChatGenerationConfig{
geminiRequest := dto.GeminiChatRequest{
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
TopP: textRequest.TopP,
MaxOutputTokens: textRequest.MaxTokens,
MaxOutputTokens: textRequest.GetMaxTokens(),
Seed: int64(textRequest.Seed),
},
}
@@ -156,11 +196,41 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
}
ThinkingAdaptor(&geminiRequest, info)
adaptorWithExtraBody := false
safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList))
if len(textRequest.ExtraBody) > 0 {
if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
var extraBody map[string]interface{}
if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
return nil, fmt.Errorf("invalid extra body: %w", err)
}
// eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}}
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
adaptorWithExtraBody = true
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
if budget, ok := thinkingConfig["thinking_budget"].(float64); ok {
budgetInt := int(budget)
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(budgetInt),
IncludeThoughts: true,
}
} else {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
}
}
}
}
}
if !adaptorWithExtraBody {
ThinkingAdaptor(&geminiRequest, info, textRequest)
}
safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList))
for _, category := range SafetySettingList {
safetySettings = append(safetySettings, GeminiChatSafetySettings{
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: model_setting.GetGeminiSafetySetting(category),
})
@@ -198,17 +268,17 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
functions = append(functions, tool.Function)
}
if codeExecution {
geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
CodeExecution: make(map[string]string),
})
}
if googleSearch {
geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
GoogleSearch: make(map[string]string),
})
}
if len(functions) > 0 {
geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
FunctionDeclarations: functions,
})
}
@@ -238,7 +308,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
continue
} else if message.Role == "tool" || message.Role == "function" {
if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" {
geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: "user",
})
}
@@ -265,18 +335,18 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
}
functionResp := &FunctionResponse{
functionResp := &dto.GeminiFunctionResponse{
Name: name,
Response: contentMap,
}
*parts = append(*parts, GeminiPart{
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
})
continue
}
var parts []GeminiPart
content := GeminiChatContent{
var parts []dto.GeminiPart
content := dto.GeminiChatContent{
Role: message.Role,
}
// isToolCall := false
@@ -290,8 +360,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments)
}
}
toolCall := GeminiPart{
FunctionCall: &FunctionCall{
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: call.Function.Name,
Arguments: args,
},
@@ -308,7 +378,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if part.Text == "" {
continue
}
parts = append(parts, GeminiPart{
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
} else if part.Type == dto.ContentTypeImageURL {
@@ -331,8 +401,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: fileData.MimeType, // 使用原始的 MimeType因为大小写可能对API有意义
Data: fileData.Base64Data,
},
@@ -342,8 +412,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if err != nil {
return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
@@ -357,8 +427,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
@@ -371,8 +441,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String,
},
@@ -392,8 +462,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
if len(system_content) > 0 {
geminiRequest.SystemInstructions = &GeminiChatContent{
Parts: []GeminiPart{
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{
Text: strings.Join(system_content, "\n"),
},
@@ -636,7 +706,7 @@ func unescapeMapOrSlice(data interface{}) interface{} {
return data
}
func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse {
func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
var argsBytes []byte
var err error
if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok {
@@ -658,7 +728,7 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse {
}
}
func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse {
func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: helper.GetResponseID(c),
Object: "chat.completion",
@@ -725,10 +795,9 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dt
return &fullTextResponse
}
func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) {
func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
isStop := false
hasImage := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
isStop = true
@@ -759,7 +828,6 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
if strings.HasPrefix(part.InlineData.MimeType, "image") {
imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")"
texts = append(texts, imgText)
hasImage = true
}
} else if part.FunctionCall != nil {
isTools = true
@@ -796,7 +864,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Choices = choices
return &response, isStop, hasImage
return &response, isStop
}
func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
@@ -816,7 +884,7 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
if err != nil {
return fmt.Errorf("failed to marshal stream response: %w", err)
}
openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, info.ShouldIncludeUsage)
openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false)
return nil
}
@@ -824,23 +892,31 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
// responseText := ""
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
responseText := strings.Builder{}
var usage = &dto.Usage{}
var imageCount int
respCount := 0
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
var geminiResponse GeminiChatResponse
var geminiResponse dto.GeminiChatResponse
err := common.UnmarshalJsonStr(data, &geminiResponse)
if err != nil {
common.LogError(c, "error unmarshalling stream response: "+err.Error())
return false
}
response, isStop, hasImage := streamResponseGeminiChat2OpenAI(&geminiResponse)
if hasImage {
imageCount++
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
}
}
response, isStop := streamResponseGeminiChat2OpenAI(&geminiResponse)
response.Id = id
response.Created = createAt
response.Model = info.UpstreamModelName
@@ -858,7 +934,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
}
}
if respCount == 0 {
if info.SendResponseCount == 0 {
// send first response
err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil))
if err != nil {
@@ -873,11 +949,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
if isStop {
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop))
}
respCount++
return true
})
if respCount == 0 {
if info.SendResponseCount == 0 {
// 空补全,报错不计费
// empty response, throw an error
return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
@@ -892,6 +967,16 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
if usage.CompletionTokens == 0 {
str := responseText.String()
if len(str) > 0 {
usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
// 空补全,不需要使用量
usage = &dto.Usage{}
}
}
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
err := handleFinalStream(c, info, response)
if err != nil {
@@ -913,7 +998,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
if common.DebugEnabled {
println(string(responseBody))
}
var geminiResponse GeminiChatResponse
var geminiResponse dto.GeminiChatResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
@@ -959,7 +1044,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
var geminiResponse GeminiEmbeddingResponse
var geminiResponse dto.GeminiEmbeddingResponse
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -1005,7 +1090,7 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
_ = resp.Body.Close()
var geminiResponse GeminiImageResponse
var geminiResponse dto.GeminiImageResponse
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
@@ -13,11 +12,18 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
return nil, errors.New("not implemented")
}

View File

@@ -19,6 +19,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -16,6 +16,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -71,7 +71,7 @@ func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAI
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
MaxTokens: request.MaxTokens,
MaxTokens: request.GetMaxTokens(),
Tools: request.Tools,
ToolChoice: request.ToolChoice,
}

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -17,6 +17,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{}
openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request)

View File

@@ -60,7 +60,7 @@ func requestOpenAI2Ollama(request *dto.GeneralOpenAIRequest) (*OllamaRequest, er
TopK: request.TopK,
Stop: Stop,
Tools: request.Tools,
MaxTokens: request.MaxTokens,
MaxTokens: request.GetMaxTokens(),
ResponseFormat: request.ResponseFormat,
FrequencyPenalty: request.FrequencyPenalty,
PresencePenalty: request.PresencePenalty,

View File

@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -34,6 +35,30 @@ type Adaptor struct {
ResponseFormat string
}
// parseReasoningEffortFromModelSuffix 从模型名称中解析推理级别
// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc...
// minimal effort only available in gpt-5
func parseReasoningEffortFromModelSuffix(model string) (string, string) {
effortSuffixes := []string{"-high", "-minimal", "-low", "-medium"}
for _, suffix := range effortSuffixes {
if strings.HasSuffix(model, suffix) {
effort := strings.TrimPrefix(suffix, "-")
originModel := strings.TrimSuffix(model, suffix)
return effort, originModel
}
}
return "", model
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
// 使用 service.GeminiToOpenAIRequest 转换请求格式
openaiRequest, err := service.GeminiToOpenAIRequest(request, info)
if err != nil {
return nil, err
}
return a.ConvertOpenAIRequest(c, info, openaiRequest)
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
//if !strings.Contains(request.Model, "claude") {
// return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model)
@@ -64,9 +89,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayFormat == relaycommon.RelayFormatClaude {
return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil
}
if info.RelayMode == relayconstant.RelayModeRealtime {
if strings.HasPrefix(info.BaseUrl, "https://") {
baseUrl := strings.TrimPrefix(info.BaseUrl, "https://")
@@ -113,6 +135,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
return url, nil
default:
if info.RelayFormat == relaycommon.RelayFormatClaude || info.RelayFormat == relaycommon.RelayFormatGemini {
return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil
}
return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil
}
}
@@ -163,23 +188,47 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
if strings.HasSuffix(info.UpstreamModelName, "-thinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
if len(request.Reasoning) == 0 {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "" {
reasoning["effort"] = request.ReasoningEffort
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
}
}
if strings.HasPrefix(request.Model, "o") {
if strings.HasPrefix(request.Model, "o") || strings.HasPrefix(request.Model, "gpt-5") {
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = 0
}
request.Temperature = nil
if strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
request.Model = strings.TrimSuffix(request.Model, "-high")
} else if strings.HasSuffix(request.Model, "-low") {
request.ReasoningEffort = "low"
request.Model = strings.TrimSuffix(request.Model, "-low")
} else if strings.HasSuffix(request.Model, "-medium") {
request.ReasoningEffort = "medium"
request.Model = strings.TrimSuffix(request.Model, "-medium")
if strings.HasPrefix(request.Model, "o") {
request.Temperature = nil
}
if strings.HasPrefix(request.Model, "gpt-5") {
if request.Model != "gpt-5-chat-latest" {
request.Temperature = nil
}
}
// 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
request.ReasoningEffort = effort
request.Model = originModel
}
info.ReasoningEffort = request.ReasoningEffort
info.UpstreamModelName = request.Model
@@ -396,16 +445,11 @@ func detectImageMimeType(filename string) string {
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 模型后缀转换 reasoning effort
if strings.HasSuffix(request.Model, "-high") {
request.Reasoning.Effort = "high"
request.Model = strings.TrimSuffix(request.Model, "-high")
} else if strings.HasSuffix(request.Model, "-low") {
request.Reasoning.Effort = "low"
request.Model = strings.TrimSuffix(request.Model, "-low")
} else if strings.HasSuffix(request.Model, "-medium") {
request.Reasoning.Effort = "medium"
request.Model = strings.TrimSuffix(request.Model, "-medium")
// 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
request.Reasoning.Effort = effort
request.Model = originModel
}
return request, nil
}

View File

@@ -18,6 +18,9 @@ var ModelList = []string{
"o3-mini-high", "o3-mini-2025-01-31-high",
"o3-mini-low", "o3-mini-2025-01-31-low",
"o3-mini-medium", "o3-mini-2025-01-31-medium",
"gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest",
"gpt-5-mini", "gpt-5-mini-2025-08-07",
"gpt-5-nano", "gpt-5-nano-2025-08-07",
"o1", "o1-2024-12-17",
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01",
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17",

View File

@@ -2,6 +2,8 @@ package openai
import (
"encoding/json"
"errors"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
@@ -16,11 +18,14 @@ import (
// 辅助函数
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
info.SendResponseCount++
switch info.RelayFormat {
case relaycommon.RelayFormatOpenAI:
return sendStreamData(c, info, data, forceFormat, thinkToContent)
case relaycommon.RelayFormatClaude:
return handleClaudeFormat(c, data, info)
case relaycommon.RelayFormatGemini:
return handleGeminiFormat(c, data, info)
}
return nil
}
@@ -41,6 +46,36 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return nil
}
func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error {
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil {
common.LogError(c, "failed to unmarshal stream response: "+err.Error())
return err
}
geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info)
// 如果返回 nil表示没有实际内容跳过发送
if geminiResponse == nil {
return nil
}
geminiResponseStr, err := common.Marshal(geminiResponse)
if err != nil {
common.LogError(c, "failed to marshal gemini response: "+err.Error())
return err
}
// send gemini format response
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
} else {
return errors.New("streaming error: flusher not found")
}
return nil
}
func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, responseTextBuilder *strings.Builder, toolCount *int) error {
for _, choice := range streamResponse.Choices {
responseTextBuilder.WriteString(choice.Delta.GetContentString())
@@ -185,6 +220,37 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
for _, resp := range claudeResponses {
_ = helper.ClaudeData(c, *resp)
}
case relaycommon.RelayFormatGemini:
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
}
// 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段
// 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空finishReason 为 STOP 的响应
// 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
// 暂不知是否有程序会不兼容。
geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info)
// openai 流响应开头的空数据
if geminiResponse == nil {
return
}
geminiResponseStr, err := common.Marshal(geminiResponse)
if err != nil {
common.SysError("error marshalling gemini response: " + err.Error())
return
}
// 发送最终的 Gemini 响应
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
}
}

View File

@@ -223,6 +223,13 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
responseBody = claudeRespStr
case relaycommon.RelayFormatGemini:
geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info)
geminiRespStr, err := common.Marshal(geminiResp)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
responseBody = geminiRespStr
}
common.IOCopyBytesGracefully(c, resp, responseBody)

View File

@@ -37,9 +37,14 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// compute usage
usage := dto.Usage{}
usage.PromptTokens = responsesResponse.Usage.InputTokens
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
usage.TotalTokens = responsesResponse.Usage.TotalTokens
if responsesResponse.Usage != nil {
usage.PromptTokens = responsesResponse.Usage.InputTokens
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
usage.TotalTokens = responsesResponse.Usage.TotalTokens
if responsesResponse.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
}
}
// 解析 Tools 用量
for _, tool := range responsesResponse.Tools {
info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++
@@ -64,9 +69,14 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
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.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
}
case "response.output_text.delta":
// 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta)

View File

@@ -17,6 +17,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -18,30 +18,6 @@ import (
// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body
// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body
func requestOpenAI2PaLM(textRequest dto.GeneralOpenAIRequest) *PaLMChatRequest {
palmRequest := PaLMChatRequest{
Prompt: PaLMPrompt{
Messages: make([]PaLMChatMessage, 0, len(textRequest.Messages)),
},
Temperature: textRequest.Temperature,
CandidateCount: textRequest.N,
TopP: textRequest.TopP,
TopK: textRequest.MaxTokens,
}
for _, message := range textRequest.Messages {
palmMessage := PaLMChatMessage{
Content: message.StringContent(),
}
if message.Role == "user" {
palmMessage.Author = "0"
} else {
palmMessage.Author = "1"
}
palmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage)
}
return &palmRequest
}
func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),

View File

@@ -17,6 +17,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -16,6 +16,6 @@ func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpen
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
MaxTokens: request.MaxTokens,
MaxTokens: request.GetMaxTokens(),
}
}

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)

View File

@@ -25,6 +25,11 @@ type Adaptor struct {
Timestamp int64
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -35,6 +35,7 @@ var claudeModelMap = map[string]string{
"claude-3-7-sonnet-20250219": "claude-3-7-sonnet@20250219",
"claude-sonnet-4-20250514": "claude-sonnet-4@20250514",
"claude-opus-4-20250514": "claude-opus-4@20250514",
"claude-opus-4-1-20250805": "claude-opus-4-1@20250805",
}
const anthropicVersion = "vertex-2023-10-16"
@@ -44,6 +45,11 @@ type Adaptor struct {
AccountCredentials Credentials
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v)
@@ -232,7 +238,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
} else {
switch a.RequestMode {
case RequestModeClaude:
err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info)
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
case RequestModeGemini:
if info.RelayMode == constant.RelayModeGemini {
usage, err = gemini.GeminiTextGenerationHandler(c, info, resp)

View File

@@ -36,7 +36,12 @@ var Cache = asynccache.NewAsyncCache(asynccache.Options{
})
func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) {
cacheKey := fmt.Sprintf("access-token-%d", info.ChannelId)
var cacheKey string
if info.ChannelIsMultiKey {
cacheKey = fmt.Sprintf("access-token-%d-%d", info.ChannelId, info.ChannelMultiKeyIndex)
} else {
cacheKey = fmt.Sprintf("access-token-%d", info.ChannelId)
}
val, err := Cache.Get(cacheKey)
if err == nil {
return val.(string), nil

View File

@@ -23,10 +23,14 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
panic("implement me")
return nil, nil
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
@@ -191,6 +195,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", info.BaseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", info.BaseUrl), nil
default:
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
@@ -227,18 +235,8 @@ 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) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
}
case constant.RelayModeEmbeddings:
usage, err = openai.OpenaiHandler(c, info, resp)
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp)
}
adaptor := openai.Adaptor{}
usage, err = adaptor.DoResponse(c, resp, info)
return
}

View File

@@ -19,6 +19,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
//panic("implement me")

View File

@@ -17,6 +17,11 @@ type Adaptor struct {
request *dto.GeneralOpenAIRequest
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -48,7 +48,7 @@ func requestOpenAI2Xunfei(request dto.GeneralOpenAIRequest, xunfeiAppId string,
xunfeiRequest.Parameter.Chat.Domain = domain
xunfeiRequest.Parameter.Chat.Temperature = request.Temperature
xunfeiRequest.Parameter.Chat.TopK = request.N
xunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens
xunfeiRequest.Parameter.Chat.MaxTokens = request.GetMaxTokens()
xunfeiRequest.Payload.Message.Text = messages
return &xunfeiRequest
}

View File

@@ -16,6 +16,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -18,6 +18,11 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
panic("implement me")

View File

@@ -105,7 +105,7 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
MaxTokens: request.MaxTokens,
MaxTokens: request.GetMaxTokens(),
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,

View File

@@ -60,17 +60,19 @@ type ResponsesUsageInfo struct {
}
type RelayInfo struct {
ChannelType int
ChannelId int
TokenId int
TokenKey string
UserId int
UsingGroup string // 使用的分组
UserGroup string // 用户所在分组
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
isFirstResponse bool
ChannelType int
ChannelId int
ChannelIsMultiKey bool // 是否多密钥
ChannelMultiKeyIndex int // 多密钥索引
TokenId int
TokenKey string
UserId int
UsingGroup string // 使用的分组
UserGroup string // 用户所在分组
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
isFirstResponse bool
//SendLastReasoningResponse bool
ApiType int
IsStream bool
@@ -223,6 +225,9 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
userId := common.GetContextKeyInt(c, constant.ContextKeyUserId)
tokenUnlimited := common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
// firstResponseTime = time.Now() - 1 second
apiType, _ := common.ChannelType2APIType(channelType)
@@ -260,6 +265,9 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
IsFirstThinkingContent: true,
SendLastThinkingContent: false,
},
ChannelIsMultiKey: common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey),
ChannelMultiKeyIndex: common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex),
}
if strings.HasPrefix(c.Request.URL.Path, "/pg") {
info.IsPlayground = true

View File

@@ -20,8 +20,8 @@ import (
"github.com/gin-gonic/gin"
)
func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) {
request := &gemini.GeminiChatRequest{}
func getAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
request := &dto.GeminiChatRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
return nil, err
@@ -44,7 +44,7 @@ func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
// }
}
func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) {
func checkGeminiInputSensitive(textRequest *dto.GeminiChatRequest) ([]string, error) {
var inputTexts []string
for _, content := range textRequest.Contents {
for _, part := range content.Parts {
@@ -61,7 +61,7 @@ func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string,
return sensitiveWords, err
}
func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int {
func getGeminiInputTokens(req *dto.GeminiChatRequest, info *relaycommon.RelayInfo) int {
// 计算输入 token 数量
var inputTexts []string
for _, content := range req.Contents {
@@ -78,9 +78,13 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay
return inputTokens
}
func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool {
func isNoThinkingRequest(req *dto.GeminiChatRequest) bool {
if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0
configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget
if configBudget != nil && *configBudget == 0 {
// 如果思考预算为 0则认为是非思考请求
return true
}
}
return false
}
@@ -202,7 +206,12 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
}
requestBody = bytes.NewReader(body)
} else {
jsonData, err := common.Marshal(req)
// 使用 ConvertGeminiRequest 转换请求格式
convertedRequest, err := adaptor.ConvertGeminiRequest(c, relayInfo, req)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
jsonData, err := common.Marshal(convertedRequest)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}

View File

@@ -305,10 +305,10 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if userQuota <= 0 {
return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
if userQuota-preConsumedQuota < 0 {
return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
relayInfo.UserQuota = userQuota
if userQuota > 100*preConsumedQuota {
@@ -332,7 +332,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
if preConsumedQuota > 0 {
err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota)
if err != nil {
return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry())
return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
if err != nil {

View File

@@ -44,6 +44,7 @@ func SetApiRouter(router *gin.Engine) {
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout)
userRoute.GET("/epay/notify", controller.EpayNotify)
@@ -66,6 +67,13 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
// 2FA routes
selfRoute.GET("/2fa/status", controller.Get2FAStatus)
selfRoute.POST("/2fa/setup", controller.Setup2FA)
selfRoute.POST("/2fa/enable", controller.Enable2FA)
selfRoute.POST("/2fa/disable", controller.Disable2FA)
selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes)
}
adminRoute := userRoute.Group("/")
@@ -78,6 +86,10 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
// Admin 2FA routes
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
}
}
optionRoute := apiRouter.Group("/option")
@@ -120,6 +132,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
channelRoute.POST("/copy/:id", controller.CopyChannel)
channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
}
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
@@ -166,6 +179,16 @@ func SetApiRouter(router *gin.Engine) {
{
groupRoute.GET("/", controller.GetGroups)
}
prefillGroupRoute := apiRouter.Group("/prefill_group")
prefillGroupRoute.Use(middleware.AdminAuth())
{
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup)
prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
}
mjRoute := apiRouter.Group("/mj")
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
@@ -175,5 +198,28 @@ func SetApiRouter(router *gin.Engine) {
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
}
vendorRoute := apiRouter.Group("/vendors")
vendorRoute.Use(middleware.AdminAuth())
{
vendorRoute.GET("/", controller.GetAllVendors)
vendorRoute.GET("/search", controller.SearchVendors)
vendorRoute.GET("/:id", controller.GetVendorMeta)
vendorRoute.POST("/", controller.CreateVendorMeta)
vendorRoute.PUT("/", controller.UpdateVendorMeta)
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
}
modelsRoute := apiRouter.Group("/models")
modelsRoute.Use(middleware.AdminAuth())
{
modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta)
modelsRoute.GET("/:id", controller.GetModelMeta)
modelsRoute.POST("/", controller.CreateModelMeta)
modelsRoute.PUT("/", controller.UpdateModelMeta)
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
}
}
}

View File

@@ -283,7 +283,9 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" {
// should be done
info.FinishReason = *chosenChoice.FinishReason
return claudeResponses
if !info.Done {
return claudeResponses
}
}
if info.Done {
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
@@ -432,6 +434,8 @@ func stopReasonOpenAI2Claude(reason string) string {
return "end_turn"
case "stop_sequence":
return "stop_sequence"
case "length":
fallthrough
case "max_tokens":
return "max_tokens"
case "tool_calls":
@@ -448,3 +452,353 @@ func toJSONString(v interface{}) string {
}
return string(b)
}
func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
openaiRequest := &dto.GeneralOpenAIRequest{
Model: info.UpstreamModelName,
Stream: info.IsStream,
}
// 转换 messages
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
Role: convertGeminiRoleToOpenAI(content.Role),
}
// 处理 parts
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
for _, part := range content.Parts {
if part.Text != "" {
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
}
mediaContents = append(mediaContents, mediaContent)
} else if part.InlineData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
Detail: "auto",
MimeType: part.InlineData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FileData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: part.FileData.FileUri,
Detail: "auto",
MimeType: part.FileData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
// 处理 Gemini 的工具调用
toolCall := dto.ToolCallRequest{
ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
Arguments: toJSONString(part.FunctionCall.Arguments),
},
}
toolCalls = append(toolCalls, toolCall)
} else if part.FunctionResponse != nil {
// 处理 Gemini 的工具响应,创建单独的 tool 消息
toolMessage := dto.Message{
Role: "tool",
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID
}
toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
}
}
// 设置消息内容
if len(toolCalls) > 0 {
// 如果有工具调用,设置工具调用
message.SetToolCalls(toolCalls)
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
// 如果只有一个文本内容,直接设置字符串
message.Content = mediaContents[0].Text
} else if len(mediaContents) > 0 {
// 如果有多个内容或包含媒体,设置为数组
message.SetMediaContent(mediaContents)
}
// 只有当消息有内容或工具调用时才添加
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
messages = append(messages, message)
}
}
openaiRequest.Messages = messages
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP > 0 {
openaiRequest.TopP = geminiRequest.GenerationConfig.TopP
}
if geminiRequest.GenerationConfig.TopK > 0 {
openaiRequest.TopK = int(geminiRequest.GenerationConfig.TopK)
}
if geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
openaiRequest.MaxTokens = geminiRequest.GenerationConfig.MaxOutputTokens
}
// gemini stop sequences 最多 5 个openai stop 最多 4 个
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4]
}
if geminiRequest.GenerationConfig.CandidateCount > 0 {
openaiRequest.N = geminiRequest.GenerationConfig.CandidateCount
}
// 转换工具调用
if len(geminiRequest.Tools) > 0 {
var tools []dto.ToolCallRequest
for _, tool := range geminiRequest.Tools {
if tool.FunctionDeclarations != nil {
// 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest
functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest)
if ok {
for _, function := range functionDeclarations {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: function.Name,
Description: function.Description,
Parameters: function.Parameters,
},
}
tools = append(tools, openAITool)
}
}
}
}
if len(tools) > 0 {
openaiRequest.Tools = tools
}
}
// gemini system instructions
if geminiRequest.SystemInstructions != nil {
// 将系统指令作为第一条消息插入
systemMessage := dto.Message{
Role: "system",
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
}
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
}
return openaiRequest, nil
}
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
return "user"
case "model":
return "assistant"
case "function":
return "function"
default:
return "user"
}
}
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
var texts []string
for _, part := range parts {
if part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
}
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
PromptFeedback: dto.GeminiChatPromptFeedback{
SafetyRatings: []dto.GeminiChatSafetyRating{},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: openAIResponse.PromptTokens,
CandidatesTokenCount: openAIResponse.CompletionTokens,
TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens,
},
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
var finishReason string
switch choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
// 处理工具调用
toolCalls := choice.Message.ParseToolCalls()
if len(toolCalls) > 0 {
for _, toolCall := range toolCalls {
// 解析参数
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: toolCall.Function.Name,
Arguments: args,
},
}
content.Parts = append(content.Parts, part)
}
} else {
// 处理文本内容
textContent := choice.Message.StringContent()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
// 检查是否有实际内容或结束标志
hasContent := false
hasFinishReason := false
for _, choice := range openAIResponse.Choices {
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
hasContent = true
}
if choice.FinishReason != nil {
hasFinishReason = true
}
}
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
if !hasContent && !hasFinishReason {
return nil
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
PromptFeedback: dto.GeminiChatPromptFeedback{
SafetyRatings: []dto.GeminiChatSafetyRating{},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: info.PromptTokens,
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
TotalTokenCount: info.PromptTokens,
},
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
if choice.FinishReason != nil {
var finishReason string
switch *choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
}
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
// 解析参数
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: toolCall.Function.Name,
Arguments: args,
},
}
content.Parts = append(content.Parts, part)
}
} else {
// 处理文本内容
textContent := choice.Delta.GetContentString()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}

View File

@@ -93,6 +93,9 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t
if showBodyWhenFail {
newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
} else {
if common.DebugEnabled {
println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
}
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
}
return

View File

@@ -25,6 +25,16 @@ var defaultCacheRatio = map[string]float64{
"gpt-4o-mini-realtime-preview": 0.5,
"gpt-4.5-preview": 0.5,
"gpt-4.5-preview-2025-02-27": 0.5,
"gpt-4.1": 0.25,
"gpt-4.1-mini": 0.25,
"gpt-4.1-nano": 0.25,
"gpt-5": 0.1,
"gpt-5-2025-08-07": 0.1,
"gpt-5-chat-latest": 0.1,
"gpt-5-mini": 0.1,
"gpt-5-mini-2025-08-07": 0.1,
"gpt-5-nano": 0.1,
"gpt-5-nano-2025-08-07": 0.1,
"deepseek-chat": 0.25,
"deepseek-reasoner": 0.25,
"deepseek-coder": 0.25,
@@ -40,6 +50,8 @@ var defaultCacheRatio = map[string]float64{
"claude-sonnet-4-20250514-thinking": 0.1,
"claude-opus-4-20250514": 0.1,
"claude-opus-4-20250514-thinking": 0.1,
"claude-opus-4-1-20250805": 0.1,
"claude-opus-4-1-20250805-thinking": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -55,6 +67,8 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-sonnet-4-20250514-thinking": 1.25,
"claude-opus-4-20250514": 1.25,
"claude-opus-4-20250514-thinking": 1.25,
"claude-opus-4-1-20250805": 1.25,
"claude-opus-4-1-20250805-thinking": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}

View File

@@ -73,6 +73,13 @@ var defaultModelRatio = map[string]float64{
"gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
"gpt-4.5-preview": 37.5,
"gpt-4.5-preview-2025-02-27": 37.5,
"gpt-5": 0.625,
"gpt-5-2025-08-07": 0.625,
"gpt-5-chat-latest": 0.625,
"gpt-5-mini": 0.125,
"gpt-5-mini-2025-08-07": 0.125,
"gpt-5-nano": 0.025,
"gpt-5-nano-2025-08-07": 0.025,
//"gpt-3.5-turbo-0301": 0.75, //deprecated
"gpt-3.5-turbo": 0.25,
"gpt-3.5-turbo-0613": 0.75,
@@ -118,6 +125,7 @@ var defaultModelRatio = map[string]float64{
"claude-sonnet-4-20250514": 1.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
"ERNIE-4.0-8K": 0.120 * RMB,
"ERNIE-3.5-8K": 0.012 * RMB,
"ERNIE-3.5-8K-0205": 0.024 * RMB,
@@ -149,6 +157,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-preview-05-20-nothinking": 0.075,
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"text-embedding-004": 0.001,
@@ -334,12 +343,8 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
modelPriceMapMutex.RLock()
defer modelPriceMapMutex.RUnlock()
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4o-gizmo") {
name = "gpt-4o-gizmo-*"
}
name = FormatMatchingModelName(name)
price, ok := modelPriceMap[name]
if !ok {
if printErr {
@@ -373,11 +378,8 @@ func GetModelRatio(name string) (float64, bool, string) {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
name = FormatMatchingModelName(name)
ratio, ok := modelRatioMap[name]
if !ok {
return 37.5, operation_setting.SelfUseModeEnabled, name
@@ -428,12 +430,9 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error {
func GetCompletionRatio(name string) float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4o-gizmo") {
name = "gpt-4o-gizmo-*"
}
name = FormatMatchingModelName(name)
if strings.Contains(name, "/") {
if ratio, ok := CompletionRatio[name]; ok {
return ratio
@@ -458,6 +457,10 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
return 4, true
}
// gpt-5 匹配
if strings.HasPrefix(name, "gpt-5") {
return 8, true
}
// gpt-4.5-preview匹配
if strings.HasPrefix(name, "gpt-4.5-preview") {
return 2, true
@@ -512,9 +515,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
return 4, false
}
return 4, false
}
return 2.5 / 0.3, true
@@ -663,3 +663,23 @@ func GetCompletionRatioCopy() map[string]float64 {
}
return copyMap
}
// 转换模型名,减少渠道必须配置各种带参数模型
func FormatMatchingModelName(name string) string {
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")
} else if strings.HasPrefix(name, "gemini-2.5-flash") {
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
} else if strings.HasPrefix(name, "gemini-2.5-pro") {
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
}
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4o-gizmo") {
name = "gpt-4o-gizmo-*"
}
return name
}

View File

@@ -76,12 +76,13 @@ const (
)
type NewAPIError struct {
Err error
RelayError any
skipRetry bool
errorType ErrorType
errorCode ErrorCode
StatusCode int
Err error
RelayError any
skipRetry bool
recordErrorLog *bool
errorType ErrorType
errorCode ErrorCode
StatusCode int
}
func (e *NewAPIError) GetErrorCode() ErrorCode {
@@ -188,9 +189,13 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI
}
func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
if errorCode == ErrorCodeDoRequestFailed {
err = errors.New("upstream error: do request failed")
}
openaiError := OpenAIError{
Message: err.Error(),
Type: string(errorCode),
Code: errorCode,
}
return WithOpenAIError(openaiError, statusCode, ops...)
}
@@ -198,6 +203,7 @@ func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAP
func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
openaiError := OpenAIError{
Type: string(errorCode),
Code: errorCode,
}
return WithOpenAIError(openaiError, statusCode, ops...)
}
@@ -223,7 +229,11 @@ func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops
func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
code, ok := openAIError.Code.(string)
if !ok {
code = fmt.Sprintf("%v", openAIError.Code)
if openAIError.Code == nil {
code = fmt.Sprintf("%v", openAIError.Code)
} else {
code = "unknown_error"
}
}
if openAIError.Type == "" {
openAIError.Type = "upstream_error"
@@ -278,3 +288,20 @@ func ErrOptionWithSkipRetry() NewAPIErrorOptions {
e.skipRetry = true
}
}
func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
return func(e *NewAPIError) {
e.recordErrorLog = common.GetPointer(false)
}
}
func IsRecordErrorLog(e *NewAPIError) bool {
if e == nil {
return false
}
if e.recordErrorLog == nil {
// default to true if not set
return true
}
return *e.recordErrorLog
}

View File

@@ -21,6 +21,7 @@
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -1492,6 +1493,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
"query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="],
@@ -1502,7 +1505,7 @@
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
"rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
@@ -1946,8 +1949,6 @@
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
"@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
@@ -1964,6 +1965,8 @@
"@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="],
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@@ -21,6 +21,7 @@
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",

View File

@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link';
import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js';
import Task from './pages/Task/index.js';
import ModelPage from './pages/Model/index.js';
import Playground from './pages/Playground/index.js';
import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/settings/PersonalSetting.js';
@@ -71,6 +72,14 @@ function App() {
</Suspense>
}
/>
<Route
path='/console/models'
element={
<PrivateRoute>
<ModelPage />
</PrivateRoute>
}
/>
<Route
path='/console/channel'
element={

View File

@@ -50,6 +50,7 @@ import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import TwoFAVerification from './TwoFAVerification.js';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
@@ -78,6 +79,7 @@ const LoginForm = () => {
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false);
const logo = getLogo();
const systemName = getSystemName();
@@ -162,6 +164,13 @@ const LoginForm = () => {
);
const { success, message, data } = res.data;
if (success) {
// 检查是否需要2FA验证
if (data && data.require_2fa) {
setShowTwoFA(true);
setLoginLoading(false);
return;
}
userDispatch({ type: 'login', payload: data });
setUserData(data);
updateAPI();
@@ -280,6 +289,21 @@ const LoginForm = () => {
setOtherLoginOptionsLoading(false);
};
// 2FA验证成功处理
const handle2FASuccess = (data) => {
userDispatch({ type: 'login', payload: data });
setUserData(data);
updateAPI();
showSuccess('登录成功!');
navigate('/console');
};
// 返回登录页面
const handleBackToLogin = () => {
setShowTwoFA(false);
setInputs({ username: '', password: '', wechat_verification_code: '' });
};
const renderOAuthOptions = () => {
return (
<div className="flex flex-col items-center">
@@ -537,6 +561,35 @@ const LoginForm = () => {
);
};
// 2FA验证弹窗
const render2FAModal = () => {
return (
<Modal
title={
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
</svg>
</div>
两步验证
</div>
}
visible={showTwoFA}
onCancel={handleBackToLogin}
footer={null}
width={450}
centered
>
<TwoFAVerification
onSuccess={handle2FASuccess}
onBack={handleBackToLogin}
isModal={true}
/>
</Modal>
);
};
return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景模糊晕染球 */}
@@ -547,6 +600,7 @@ const LoginForm = () => {
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{render2FAModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">

View File

@@ -0,0 +1,230 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { API, showError, showSuccess } from '../../helpers';
import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui';
import React, { useState } from 'react';
const { Title, Text, Paragraph } = Typography;
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
const [loading, setLoading] = useState(false);
const [useBackupCode, setUseBackupCode] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const handleSubmit = async () => {
if (!verificationCode) {
showError('请输入验证码');
return;
}
// Validate code format
if (useBackupCode && verificationCode.length !== 8) {
showError('备用码必须是8位');
return;
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
showError('验证码必须是6位数字');
return;
}
setLoading(true);
try {
const res = await API.post('/api/user/login/2fa', {
code: verificationCode
});
if (res.data.success) {
showSuccess('登录成功');
// 保存用户信息到本地存储
localStorage.setItem('user', JSON.stringify(res.data.data));
if (onSuccess) {
onSuccess(res.data.data);
}
} else {
showError(res.data.message);
}
} catch (error) {
showError('验证失败,请重试');
} finally {
setLoading(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
if (isModal) {
return (
<div className="space-y-4">
<Paragraph className="text-gray-600 dark:text-gray-300">
请输入认证器应用显示的验证码完成登录
</Paragraph>
<Form onSubmit={handleSubmit}>
<Form.Input
field="code"
label={useBackupCode ? "备用码" : "验证码"}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size="large"
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType="submit"
type="primary"
loading={loading}
block
size="large"
style={{ marginBottom: 16 }}
>
验证并登录
</Button>
</Form>
<Divider />
<div style={{ textAlign: 'center' }}>
<Button
theme="borderless"
type="tertiary"
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
}}
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
>
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
</Button>
{onBack && (
<Button
theme="borderless"
type="tertiary"
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
返回登录
</Button>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<Text size="small" type="secondary">
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br />
每个备用码只能使用一次
</Text>
</div>
</div>
);
}
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh'
}}>
<Card style={{ width: 400, padding: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title heading={3}>两步验证</Title>
<Paragraph type="secondary">
请输入认证器应用显示的验证码完成登录
</Paragraph>
</div>
<Form onSubmit={handleSubmit}>
<Form.Input
field="code"
label={useBackupCode ? "备用码" : "验证码"}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size="large"
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType="submit"
type="primary"
loading={loading}
block
size="large"
style={{ marginBottom: 16 }}
>
验证并登录
</Button>
</Form>
<Divider />
<div style={{ textAlign: 'center' }}>
<Button
theme="borderless"
type="tertiary"
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
}}
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
>
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
</Button>
{onBack && (
<Button
theme="borderless"
type="tertiary"
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
返回登录
</Button>
)}
</div>
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}>
<Text size="small" type="secondary">
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br />
每个备用码只能使用一次
</Text>
</div>
</Card>
</div>
);
};
export default TwoFAVerification;

View File

@@ -1,609 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Space,
Button,
Form,
Card,
Typography,
Banner,
Row,
Col,
InputNumber,
Switch,
Select,
Input,
} from '@douyinfe/semi-ui';
import {
IconCode,
IconEdit,
IconPlus,
IconDelete,
IconSetting,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const JSONEditor = ({
value = '',
onChange,
field,
label,
placeholder,
extraText,
showClear = true,
template,
templateLabel,
editorType = 'keyValue', // keyValue, object, region
autosize = true,
rules = [],
formApi = null,
...props
}) => {
const { t } = useTranslation();
// 初始化JSON数据
const [jsonData, setJsonData] = useState(() => {
// 初始化时解析JSON数据
if (value && value.trim()) {
try {
const parsed = JSON.parse(value);
return parsed;
} catch (error) {
return {};
}
}
return {};
});
// 根据键数量决定默认编辑模式
const [editMode, setEditMode] = useState(() => {
// 如果初始JSON数据的键数量大于10个则默认使用手动模式
if (value && value.trim()) {
try {
const parsed = JSON.parse(value);
const keyCount = Object.keys(parsed).length;
return keyCount > 10 ? 'manual' : 'visual';
} catch (error) {
return 'visual';
}
}
return 'visual';
});
const [jsonError, setJsonError] = useState('');
// 数据同步 - 当value变化时总是更新jsonData如果JSON有效
useEffect(() => {
try {
const parsed = value && value.trim() ? JSON.parse(value) : {};
setJsonData(parsed);
setJsonError('');
} catch (error) {
console.log('JSON解析失败:', error.message);
setJsonError(error.message);
// JSON格式错误时不更新jsonData
}
}, [value]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newData) => {
setJsonData(newData);
setJsonError('');
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
// 通过formApi设置值如果提供的话
if (formApi && field) {
formApi.setValue(field, jsonString);
}
onChange?.(jsonString);
}, [onChange, formApi, field]);
// 处理手动编辑的数据变化
const handleManualChange = useCallback((newValue) => {
onChange?.(newValue);
// 验证JSON格式
if (newValue && newValue.trim()) {
try {
const parsed = JSON.parse(newValue);
setJsonError('');
// 预先准备可视化数据,但不立即应用
// 这样切换到可视化模式时数据已经准备好了
} catch (error) {
setJsonError(error.message);
}
} else {
setJsonError('');
}
}, [onChange]);
// 切换编辑模式
const toggleEditMode = useCallback(() => {
if (editMode === 'visual') {
// 从可视化模式切换到手动模式
setEditMode('manual');
} else {
// 从手动模式切换到可视化模式需要验证JSON
try {
const parsed = value && value.trim() ? JSON.parse(value) : {};
setJsonData(parsed);
setJsonError('');
setEditMode('visual');
} catch (error) {
setJsonError(error.message);
// JSON格式错误时不切换模式
return;
}
}
}, [editMode, value]);
// 添加键值对
const addKeyValue = useCallback(() => {
const newData = { ...jsonData };
const keys = Object.keys(newData);
let newKey = 'key';
let counter = 1;
while (newData.hasOwnProperty(newKey)) {
newKey = `key${counter}`;
counter++;
}
newData[newKey] = '';
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 删除键值对
const removeKeyValue = useCallback((keyToRemove) => {
const newData = { ...jsonData };
delete newData[keyToRemove];
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 更新键名
const updateKey = useCallback((oldKey, newKey) => {
if (oldKey === newKey) return;
const newData = { ...jsonData };
const value = newData[oldKey];
delete newData[oldKey];
newData[newKey] = value;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 更新值
const updateValue = useCallback((key, newValue) => {
const newData = { ...jsonData };
newData[key] = newValue;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 填入模板
const fillTemplate = useCallback(() => {
if (template) {
const templateString = JSON.stringify(template, null, 2);
// 通过formApi设置值如果提供的话
if (formApi && field) {
formApi.setValue(field, templateString);
}
// 无论哪种模式都要更新值
onChange?.(templateString);
// 如果是可视化模式同时更新jsonData
if (editMode === 'visual') {
setJsonData(template);
}
// 清除错误状态
setJsonError('');
}
}, [template, onChange, editMode, formApi, field]);
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
const entries = Object.entries(jsonData);
return (
<div className="space-y-1">
{entries.length === 0 && (
<div className="text-center py-6 px-4">
<div className="text-gray-400 mb-2">
<IconCode size={32} />
</div>
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无数据,点击下方按钮添加键值对')}
</Text>
</div>
)}
{entries.map(([key, value], index) => (
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
<Row gutter={12} align="middle">
<Col span={10}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('键名')}</Text>
<Input
placeholder={t('键名')}
value={key}
onChange={(newKey) => updateKey(key, newKey)}
size="small"
/>
</div>
</Col>
<Col span={11}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('值')}</Text>
<Input
placeholder={t('值')}
value={value}
onChange={(newValue) => updateValue(key, newValue)}
size="small"
/>
</div>
</Col>
<Col span={3}>
<div className="flex justify-center pt-4">
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
size="small"
onClick={() => removeKeyValue(key)}
className="hover:bg-red-50"
/>
</div>
</Col>
</Row>
</Card>
))}
<div className="flex justify-center pt-1">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
size="small"
theme="solid"
type="primary"
className="shadow-sm hover:shadow-md transition-shadow px-4"
>
{t('添加键值对')}
</Button>
</div>
</div>
);
};
// 渲染对象编辑器用于复杂JSON
const renderObjectEditor = () => {
const entries = Object.entries(jsonData);
return (
<div className="space-y-1">
{entries.length === 0 && (
<div className="text-center py-6 px-4">
<div className="text-gray-400 mb-2">
<IconSetting size={32} />
</div>
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无参数,点击下方按钮添加请求参数')}
</Text>
</div>
)}
{entries.map(([key, value], index) => (
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
<Row gutter={12} align="middle">
<Col span={8}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('参数名')}</Text>
<Input
placeholder={t('参数名')}
value={key}
onChange={(newKey) => updateKey(key, newKey)}
size="small"
/>
</div>
</Col>
<Col span={13}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
{renderValueInput(key, value)}
</div>
</Col>
<Col span={3}>
<div className="flex justify-center pt-4">
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
size="small"
onClick={() => removeKeyValue(key)}
className="hover:bg-red-50"
/>
</div>
</Col>
</Row>
</Card>
))}
<div className="flex justify-center pt-1">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
size="small"
theme="solid"
type="primary"
className="shadow-sm hover:shadow-md transition-shadow px-4"
>
{t('添加参数')}
</Button>
</div>
</div>
);
};
// 渲染参数值输入控件
const renderValueInput = (key, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
return (
<div className="flex items-center">
<Switch
checked={value}
onChange={(newValue) => updateValue(key, newValue)}
size="small"
/>
<Text type="tertiary" size="small" className="ml-2">
{value ? t('true') : t('false')}
</Text>
</div>
);
}
if (valueType === 'number') {
return (
<InputNumber
value={value}
onChange={(newValue) => updateValue(key, newValue)}
size="small"
style={{ width: '100%' }}
step={key === 'temperature' ? 0.1 : 1}
precision={key === 'temperature' ? 2 : 0}
placeholder={t('输入数字')}
/>
);
}
// 字符串类型或其他类型
return (
<Input
placeholder={t('参数值')}
value={String(value)}
onChange={(newValue) => {
// 尝试转换为适当的类型
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
updateValue(key, convertedValue);
}}
size="small"
/>
);
};
// 渲染区域编辑器(特殊格式)
const renderRegionEditor = () => {
const entries = Object.entries(jsonData);
const defaultEntry = entries.find(([key]) => key === 'default');
const modelEntries = entries.filter(([key]) => key !== 'default');
return (
<div className="space-y-1">
{/* 默认区域 */}
<Card className="!p-2 !border-blue-200 !bg-blue-50">
<div className="flex items-center mb-1">
<Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
</div>
<Input
placeholder={t('默认区域,如: us-central1')}
value={defaultEntry ? defaultEntry[1] : ''}
onChange={(value) => updateValue('default', value)}
size="small"
/>
</Card>
{/* 模型专用区域 */}
<div className="space-y-1">
<Text strong size="small">{t('模型专用区域')}</Text>
{modelEntries.map(([modelName, region], index) => (
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
<Row gutter={12} align="middle">
<Col span={10}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('模型名称')}</Text>
<Input
placeholder={t('模型名称')}
value={modelName}
onChange={(newKey) => updateKey(modelName, newKey)}
size="small"
/>
</div>
</Col>
<Col span={11}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('区域')}</Text>
<Input
placeholder={t('区域')}
value={region}
onChange={(newValue) => updateValue(modelName, newValue)}
size="small"
/>
</div>
</Col>
<Col span={3}>
<div className="flex justify-center pt-4">
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
size="small"
onClick={() => removeKeyValue(modelName)}
className="hover:bg-red-50"
/>
</div>
</Col>
</Row>
</Card>
))}
<div className="flex justify-center pt-1">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
size="small"
theme="solid"
type="primary"
className="shadow-sm hover:shadow-md transition-shadow px-4"
>
{t('添加模型区域')}
</Button>
</div>
</div>
</div>
);
};
// 渲染可视化编辑器
const renderVisualEditor = () => {
switch (editorType) {
case 'region':
return renderRegionEditor();
case 'object':
return renderObjectEditor();
case 'keyValue':
default:
return renderKeyValueEditor();
}
};
const hasJsonError = jsonError && jsonError.trim() !== '';
return (
<div className="space-y-1">
{/* Label统一显示在上方 */}
{label && (
<div className="flex items-center">
<Text className="text-sm font-medium text-gray-900">{label}</Text>
</div>
)}
{/* 编辑模式切换 */}
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<div className="flex items-center gap-2">
{editMode === 'visual' && (
<Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
{t('可视化模式')}
</Text>
)}
{editMode === 'manual' && (
<Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
{t('手动编辑模式')}
</Text>
)}
</div>
<div className="flex items-center gap-2">
{template && templateLabel && (
<Button
size="small"
type="tertiary"
onClick={fillTemplate}
className="!text-semi-color-primary hover:bg-blue-50 text-xs"
>
{templateLabel}
</Button>
)}
<Space size="tight">
<Button
size="small"
type={editMode === 'visual' ? 'primary' : 'tertiary'}
icon={<IconEdit />}
onClick={toggleEditMode}
disabled={editMode === 'manual' && hasJsonError}
className={editMode === 'visual' ? 'shadow-sm' : ''}
>
{t('可视化')}
</Button>
<Button
size="small"
type={editMode === 'manual' ? 'primary' : 'tertiary'}
icon={<IconCode />}
onClick={toggleEditMode}
className={editMode === 'manual' ? 'shadow-sm' : ''}
>
{t('手动编辑')}
</Button>
</Space>
</div>
</div>
{/* JSON错误提示 */}
{hasJsonError && (
<Banner
type="danger"
description={`JSON 格式错误: ${jsonError}`}
className="!rounded-md text-sm"
/>
)}
{/* 编辑器内容 */}
{editMode === 'visual' ? (
<div>
<Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
{renderVisualEditor()}
</Card>
{/* 可视化模式下的额外文本显示在下方 */}
{extraText && (
<div className="text-xs text-gray-600 mt-0.5">
{extraText}
</div>
)}
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
value={value}
rules={rules}
style={{ display: 'none' }}
noLabel={true}
{...props}
/>
</div>
) : (
<Form.TextArea
field={field}
placeholder={placeholder}
value={value}
onChange={handleManualChange}
showClear={showClear}
rows={Math.max(8, value ? value.split('\n').length : 8)}
rules={rules}
noLabel={true}
{...props}
/>
)}
{/* 额外文本在手动编辑模式下显示 */}
{extraText && editMode === 'manual' && (
<div className="text-xs text-gray-600">
{extraText}
</div>
)}
</div>
);
};
export default JSONEditor;

View File

@@ -112,6 +112,7 @@ const CardPro = ({
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type="tertiary"
size="small"
theme='outline'
block
>
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}

View File

@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
/**
* CardTable 响应式表格组件
@@ -40,25 +41,8 @@ const CardTable = ({
}) => {
const isMobile = useIsMobile();
const { t } = useTranslation();
const [showSkeleton, setShowSkeleton] = useState(loading);
const loadingStartRef = useRef(Date.now());
useEffect(() => {
if (loading) {
loadingStartRef.current = Date.now();
setShowSkeleton(true);
} else {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
if (remaining === 0) {
setShowSkeleton(false);
} else {
const timer = setTimeout(() => setShowSkeleton(false), remaining);
return () => clearTimeout(timer);
}
}
}, [loading]);
const showSkeleton = useMinimumLoadingTime(loading);
const getRowKey = (record, index) => {
if (typeof rowKey === 'function') return rowKey(record);

View File

@@ -0,0 +1,669 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Typography,
Banner,
Tabs,
TabPane,
Card,
Input,
InputNumber,
Switch,
TextArea,
Row,
Col,
Divider,
} from '@douyinfe/semi-ui';
import {
IconCode,
IconPlus,
IconDelete,
IconRefresh,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const JSONEditor = ({
value = '',
onChange,
field,
label,
placeholder,
extraText,
extraFooter,
showClear = true,
template,
templateLabel,
editorType = 'keyValue',
rules = [],
formApi = null,
...props
}) => {
const { t } = useTranslation();
// 初始化JSON数据
const [jsonData, setJsonData] = useState(() => {
// 初始化时解析JSON数据
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
return parsed;
} catch (error) {
return {};
}
}
if (typeof value === 'object' && value !== null) {
return value;
}
return {};
});
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
return '';
});
// 根据键数量决定默认编辑模式
const [editMode, setEditMode] = useState(() => {
// 如果初始JSON数据的键数量大于10个则默认使用手动模式
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
const keyCount = Object.keys(parsed).length;
return keyCount > 10 ? 'manual' : 'visual';
} catch (error) {
// JSON无效时默认显示手动编辑模式
return 'manual';
}
}
return 'visual';
});
const [jsonError, setJsonError] = useState('');
// 数据同步 - 当value变化时总是更新jsonData如果JSON有效
useEffect(() => {
try {
let parsed = {};
if (typeof value === 'string' && value.trim()) {
parsed = JSON.parse(value);
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
setJsonData(parsed);
setJsonError('');
} catch (error) {
console.log('JSON解析失败:', error.message);
setJsonError(error.message);
// JSON格式错误时不更新jsonData
}
}, [value]);
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
useEffect(() => {
if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value);
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
else setManualText('');
}
}, [value, editMode]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newData) => {
setJsonData(newData);
setJsonError('');
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
// 通过formApi设置值如果提供的话
if (formApi && field) {
formApi.setValue(field, jsonString);
}
onChange?.(jsonString);
}, [onChange, formApi, field]);
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
const handleManualChange = useCallback((newValue) => {
setManualText(newValue);
if (newValue && newValue.trim()) {
try {
JSON.parse(newValue);
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
// 无效 JSON 时不回传,避免外部值把输入重置
}
} else {
setJsonError('');
onChange?.('');
}
}, [onChange]);
// 切换编辑模式
const toggleEditMode = useCallback(() => {
if (editMode === 'visual') {
// 从可视化模式切换到手动模式
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
setEditMode('manual');
} else {
// 从手动模式切换到可视化模式需要验证JSON
try {
let parsed = {};
if (manualText && manualText.trim()) {
parsed = JSON.parse(manualText);
} else if (typeof value === 'string' && value.trim()) {
parsed = JSON.parse(value);
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
setJsonData(parsed);
setJsonError('');
setEditMode('visual');
} catch (error) {
setJsonError(error.message);
// JSON格式错误时不切换模式
return;
}
}
}, [editMode, value, manualText, jsonData]);
// 添加键值对
const addKeyValue = useCallback(() => {
const newData = { ...jsonData };
const keys = Object.keys(newData);
let counter = 1;
let newKey = `field_${counter}`;
while (newData.hasOwnProperty(newKey)) {
counter += 1;
newKey = `field_${counter}`;
}
newData[newKey] = '';
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 删除键值对
const removeKeyValue = useCallback((keyToRemove) => {
const newData = { ...jsonData };
delete newData[keyToRemove];
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 更新键名
const updateKey = useCallback((oldKey, newKey) => {
if (oldKey === newKey || !newKey) return;
const newData = {};
Object.entries(jsonData).forEach(([k, v]) => {
if (k === oldKey) {
newData[newKey] = v;
} else {
newData[k] = v;
}
});
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 更新值
const updateValue = useCallback((key, newValue) => {
const newData = { ...jsonData };
newData[key] = newValue;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 填入模板
const fillTemplate = useCallback(() => {
if (template) {
const templateString = JSON.stringify(template, null, 2);
// 通过formApi设置值如果提供的话
if (formApi && field) {
formApi.setValue(field, templateString);
}
// 同步内部与外部值,避免出现杂字符
setManualText(templateString);
setJsonData(template);
onChange?.(templateString);
// 清除错误状态
setJsonError('');
}
}, [template, onChange, editMode, formApi, field]);
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
if (typeof jsonData !== 'object' || jsonData === null) {
return (
<div className="text-center py-6 px-4">
<div className="text-gray-400 mb-2">
<IconCode size={32} />
</div>
<Text type="tertiary" className="text-gray-500 text-sm">
{t('无效的JSON数据请检查格式')}
</Text>
</div>
);
}
const entries = Object.entries(jsonData);
return (
<div className="space-y-1">
{entries.length === 0 && (
<div className="text-center py-6 px-4">
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无数据,点击下方按钮添加键值对')}
</Text>
</div>
)}
{entries.map(([key, value], index) => (
<Row key={index} gutter={8} align="middle">
<Col span={6}>
<Input
placeholder={t('键名')}
value={key}
onChange={(newKey) => updateKey(key, newKey)}
/>
</Col>
<Col span={16}>
{renderValueInput(key, value)}
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(key)}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
<div className="mt-2 flex justify-center">
<Button
icon={<IconPlus />}
type="primary"
theme="outline"
onClick={addKeyValue}
>
{t('添加键值对')}
</Button>
</div>
</div>
);
};
// 添加嵌套对象
const flattenObject = useCallback((parentKey) => {
const newData = { ...jsonData };
let primitive = '';
const obj = newData[parentKey];
if (obj && typeof obj === 'object') {
const firstKey = Object.keys(obj)[0];
if (firstKey !== undefined) {
const firstVal = obj[firstKey];
if (typeof firstVal !== 'object') primitive = firstVal;
}
}
newData[parentKey] = primitive;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
const addNestedObject = useCallback((parentKey) => {
const newData = { ...jsonData };
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
newData[parentKey] = {};
}
const existingKeys = Object.keys(newData[parentKey]);
let counter = 1;
let newKey = `field_${counter}`;
while (newData[parentKey].hasOwnProperty(newKey)) {
counter += 1;
newKey = `field_${counter}`;
}
newData[parentKey][newKey] = '';
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 渲染参数值输入控件(支持嵌套)
const renderValueInput = (key, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
return (
<div className="flex items-center">
<Switch
checked={value}
onChange={(newValue) => updateValue(key, newValue)}
/>
<Text type="tertiary" className="ml-2">
{value ? t('true') : t('false')}
</Text>
</div>
);
}
if (valueType === 'number') {
return (
<InputNumber
value={value}
onChange={(newValue) => updateValue(key, newValue)}
style={{ width: '100%' }}
step={key === 'temperature' ? 0.1 : 1}
precision={key === 'temperature' ? 2 : 0}
placeholder={t('输入数字')}
/>
);
}
if (valueType === 'object' && value !== null) {
// 渲染嵌套对象
const entries = Object.entries(value);
return (
<Card className="!rounded-2xl">
{entries.length === 0 && (
<Text type="tertiary" className="text-gray-500 text-xs">
{t('空对象,点击下方加号添加字段')}
</Text>
)}
{entries.map(([nestedKey, nestedValue], index) => (
<Row key={index} gutter={4} align="middle" className="mb-1">
<Col span={8}>
<Input
size="small"
placeholder={t('键名')}
value={nestedKey}
onChange={(newKey) => {
const newData = { ...jsonData };
const oldValue = newData[key][nestedKey];
delete newData[key][nestedKey];
newData[key][newKey] = oldValue;
handleVisualChange(newData);
}}
/>
</Col>
<Col span={14}>
{typeof nestedValue === 'object' && nestedValue !== null ? (
<TextArea
size="small"
rows={2}
value={JSON.stringify(nestedValue, null, 2)}
onChange={(txt) => {
try {
const obj = txt.trim() ? JSON.parse(txt) : {};
const newData = { ...jsonData };
newData[key][nestedKey] = obj;
handleVisualChange(newData);
} catch {
// ignore parse error
}
}}
/>
) : (
<Input
size="small"
placeholder={t('值')}
value={String(nestedValue)}
onChange={(newValue) => {
const newData = { ...jsonData };
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
newData[key][nestedKey] = convertedValue;
handleVisualChange(newData);
}}
/>
)}
</Col>
<Col span={2}>
<Button
size="small"
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => {
const newData = { ...jsonData };
delete newData[key][nestedKey];
handleVisualChange(newData);
}}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
<div className="flex justify-center mt-1 gap-2">
<Button
size="small"
icon={<IconPlus />}
type="tertiary"
onClick={() => addNestedObject(key)}
>
{t('添加字段')}
</Button>
<Button
size="small"
icon={<IconRefresh />}
type="tertiary"
onClick={() => flattenObject(key)}
>
{t('转换为值')}
</Button>
</div>
</Card>
);
}
// 字符串或其他原始类型
return (
<div className="flex items-center gap-1">
<Input
placeholder={t('参数值')}
value={String(value)}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
updateValue(key, convertedValue);
}}
/>
<Button
icon={<IconPlus />}
type="tertiary"
onClick={() => {
// 将当前值转换为对象
const newData = { ...jsonData };
newData[key] = { '1': value };
handleVisualChange(newData);
}}
title={t('转换为对象')}
/>
</div>
);
};
// 渲染区域编辑器(特殊格式)
const renderRegionEditor = () => {
const entries = Object.entries(jsonData);
const defaultEntry = entries.find(([key]) => key === 'default');
const modelEntries = entries.filter(([key]) => key !== 'default');
return (
<div className="space-y-2">
{/* 默认区域 */}
<Form.Slot label={t('默认区域')}>
<Input
placeholder={t('默认区域,如: us-central1')}
value={defaultEntry ? defaultEntry[1] : ''}
onChange={(value) => updateValue('default', value)}
/>
</Form.Slot>
{/* 模型专用区域 */}
<Form.Slot label={t('模型专用区域')}>
<div>
{modelEntries.map(([modelName, region], index) => (
<Row key={index} gutter={8} align="middle" className="mb-2">
<Col span={10}>
<Input
placeholder={t('模型名称')}
value={modelName}
onChange={(newKey) => updateKey(modelName, newKey)}
/>
</Col>
<Col span={12}>
<Input
placeholder={t('区域')}
value={region}
onChange={(newValue) => updateValue(modelName, newValue)}
/>
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(modelName)}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
<div className="mt-2 flex justify-center">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
type="primary"
theme="outline"
>
{t('添加模型区域')}
</Button>
</div>
</div>
</Form.Slot>
</div>
);
};
// 渲染可视化编辑器
const renderVisualEditor = () => {
switch (editorType) {
case 'region':
return renderRegionEditor();
case 'object':
case 'keyValue':
default:
return renderKeyValueEditor();
}
};
const hasJsonError = jsonError && jsonError.trim() !== '';
return (
<Form.Slot label={label}>
<Card
header={
<div className="flex justify-between items-center">
<Tabs
type="slash"
activeKey={editMode}
onChange={(key) => {
if (key === 'manual' && editMode === 'visual') {
setEditMode('manual');
} else if (key === 'visual' && editMode === 'manual') {
toggleEditMode();
}
}}
>
<TabPane tab={t('可视化')} itemKey="visual" />
<TabPane tab={t('手动编辑')} itemKey="manual" />
</Tabs>
{template && templateLabel && (
<Button
type="tertiary"
onClick={fillTemplate}
size="small"
>
{templateLabel}
</Button>
)}
</div>
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className="!rounded-2xl"
>
{/* JSON错误提示 */}
{hasJsonError && (
<Banner
type="danger"
description={`JSON 格式错误: ${jsonError}`}
className="mb-3"
/>
)}
{/* 编辑器内容 */}
{editMode === 'visual' ? (
<div>
{renderVisualEditor()}
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
value={value}
rules={rules}
style={{ display: 'none' }}
noLabel={true}
{...props}
/>
</div>
) : (
<div>
<TextArea
placeholder={placeholder}
value={manualText}
onChange={handleManualChange}
showClear={showClear}
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
/>
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
value={value}
rules={rules}
style={{ display: 'none' }}
noLabel={true}
{...props}
/>
</div>
)}
{/* 额外文本显示在卡片底部 */}
{extraText && (
<Divider margin='12px' align='center'>
<Text type="tertiary" size="small">{extraText}</Text>
</Divider>
)}
{extraFooter && (
<div className="mt-1">
{extraFooter}
</div>
)}
</Card>
</Form.Slot>
);
};
export default JSONEditor;

View File

@@ -0,0 +1,60 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
const { Text } = Typography;
// 通用渲染函数限制项目数量显示支持popover展开
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
if (!items || items.length === 0) return '-';
const displayItems = items.slice(0, maxDisplay);
const remainingItems = items.slice(maxDisplay);
return (
<Space spacing={1} wrap>
{displayItems.map((item, idx) => renderItem(item, idx))}
{remainingItems.length > 0 && (
<Popover
content={
<div className='p-2'>
<Space spacing={1} wrap>
{remainingItems.map((item, idx) => renderItem(item, idx))}
</Space>
</div>
}
position='top'
>
<Tag size='small' shape='circle' color='grey'>
+{remainingItems.length}
</Tag>
</Popover>
)}
</Space>
);
}
// 渲染描述字段长文本支持tooltip
export const renderDescription = (text, maxWidth = 200) => {
return (
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
{text || '-'}
</Text>
);
};

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