Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c0276156
|
||
|
|
8e6ed0317b | ||
|
|
d09216bbde | ||
|
|
5152a4080e | ||
|
|
ceddfd3d6b |
No files matched your search
@@ -2,15 +2,26 @@
|
||||
Agent-only issue body. Humans use GitHub issue forms.
|
||||
|
||||
Keep every heading. If a section does not apply, write why; do not delete it.
|
||||
Match the user's language in the filled answers. Quote what the user said; do not invent facts.
|
||||
Match the user's language in the filled answers. Do not invent facts.
|
||||
|
||||
User request: quote the request the user made to the agent as faithfully as
|
||||
possible. Keep the original language and line breaks. Do not rewrite,
|
||||
summarize, translate, or turn it into an issue description.
|
||||
|
||||
Keep filled answers short and factual. Do not paste unfiltered AI-generated
|
||||
text, speculation, or an implementation plan in the issue body or in later
|
||||
comments. Extract the evidence maintainers need to review. Repeated
|
||||
submissions of unfiltered AI-generated text may result in a block.
|
||||
|
||||
If a field below is missing from the conversation, ask the user that question
|
||||
and wait. Ask for the facts themselves. Do not tell the user to confirm a
|
||||
template, tick checkboxes, or acknowledge the guidelines.
|
||||
|
||||
Before filing, refuse out-of-scope requests listed below. Then search the docs,
|
||||
DeepWiki, README, and the code. If this is a usage, configuration, or
|
||||
integration question, answer the user from that material and do not file.
|
||||
Do not file unless every required condition is met. If the request is out of
|
||||
scope (including pass-through), is a usage/configuration/integration question,
|
||||
is missing required facts, or the body would be unfiltered AI-generated text,
|
||||
tell the user and **stop**. Do not create the issue. Do not file a partial
|
||||
issue. Do not fill gaps with speculation.
|
||||
-->
|
||||
|
||||
## Agent
|
||||
@@ -23,7 +34,12 @@ integration question, answer the user from that material and do not file.
|
||||
|
||||
## User request
|
||||
|
||||
(verbatim or close paraphrase)
|
||||
Quote the request the user made to the agent as faithfully as possible.
|
||||
Keep the original language and line breaks. Do not rewrite, summarize,
|
||||
translate, or turn it into an issue description.
|
||||
|
||||
- Verbatim:
|
||||
- Later constraints or corrections from the user (quote, or none):
|
||||
|
||||
## Out of scope — refuse
|
||||
|
||||
@@ -35,7 +51,7 @@ accept it, point them to the right place when there is one, and **do not file**.
|
||||
- Third-party API wrappers
|
||||
- Compatibility issues from exposing a Codex endpoint as a general-purpose API through a reverse proxy
|
||||
- Codex API-specific protocol or behavior treated as standard OpenAI API behavior (confirm with the channel or API provider)
|
||||
- Pass-through mode forwarding (pass-through forwards as-is; verify upstream yourself)
|
||||
- Pass-through mode forwarding (do not file: pass-through sends content as-is and does not go through new-api processing logic)
|
||||
- Relay reports that only paste an upstream error, with no direct-upstream vs new-api comparison
|
||||
- Third-party hosting sites, relay services, or API services (contact their operator)
|
||||
- Usage, configuration, or integration questions (answer from docs and code instead)
|
||||
@@ -43,6 +59,17 @@ accept it, point them to the right place when there is one, and **do not file**.
|
||||
- Matched: yes/no
|
||||
- If yes, what was told to the user (stop here; do not file):
|
||||
|
||||
## File gate — do not file unless all are satisfied
|
||||
|
||||
If any item fails, tell the user and **stop**. Do not create the issue.
|
||||
|
||||
- Out of scope (including pass-through): no
|
||||
- Usage / configuration / integration (answered instead of filing): no
|
||||
- Required facts present without invention (actual behavior, impact, frequency, evidence in new-api, applicable type fields): yes
|
||||
- Body is short and factual; no unfiltered AI-generated text: yes
|
||||
- File: yes/no
|
||||
- If no, what was told to the user (stop here):
|
||||
|
||||
## Kind
|
||||
|
||||
- [ ] Bug
|
||||
@@ -69,7 +96,7 @@ Search these yourself before filing. Do not send the user to "read the docs firs
|
||||
|
||||
## Problem facts
|
||||
|
||||
Ask the user for every item and write it down:
|
||||
Ask the user for every item and write it down. Facts only; do not paste unfiltered AI-generated text:
|
||||
|
||||
- Actual behavior:
|
||||
- Impact:
|
||||
@@ -134,6 +161,7 @@ Fill every applicable type. Write "not applicable" for the rest. Ask the user fo
|
||||
## Research
|
||||
|
||||
Open the docs and code. Do not write "already checked" without sources.
|
||||
Do not replace the fields below with unfiltered AI-generated text.
|
||||
|
||||
### Docs
|
||||
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
Agent-only PR body. Humans use `.github/PULL_REQUEST_TEMPLATE.md`.
|
||||
|
||||
Keep every heading. If a section does not apply, write why; do not delete it.
|
||||
Match the user's language in the filled answers. Quote what the user said; do not invent facts.
|
||||
Match the user's language in the filled answers. Do not invent facts.
|
||||
|
||||
User request: quote the request the user made to the agent as faithfully as
|
||||
possible. Keep the original language and line breaks. Do not rewrite,
|
||||
summarize, translate, or turn it into a PR description.
|
||||
|
||||
Keep the body short and factual. Do not paste unfiltered AI-generated text
|
||||
in the PR body or in later comments. Repeated submissions of this kind may
|
||||
result in a block. Verification must be commands actually run and what was
|
||||
observed. Stating only that `go build` or tests passed is not proof.
|
||||
|
||||
New features: link an issue; if none exists, file one first with `.agents/github/ISSUE.md`.
|
||||
Large or directional changes: maintainer agreement on that issue before this PR.
|
||||
@@ -12,10 +21,11 @@ frequency, evidence that the problem is in new-api, or the applicable
|
||||
relay / billing / frontend / deployment items, ask the user those questions
|
||||
and wait. Ask for the facts. Do not tell the user to confirm a template.
|
||||
|
||||
Before opening, refuse the same out-of-scope list as `.agents/github/ISSUE.md`
|
||||
(Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy
|
||||
compatibility, pass-through-only forwarding, third-party hosts, usage questions).
|
||||
Tell the user and do not open a PR.
|
||||
Do not open a PR unless every required condition is met. If the change is out
|
||||
of scope (including pass-through-only forwarding), is a usage/configuration/
|
||||
integration question, is missing required issue facts, has only `go build` /
|
||||
tests-passed as verification, or the body would be unfiltered AI-generated
|
||||
text, tell the user and **stop**. Do not open the PR. Do not open a partial PR.
|
||||
|
||||
Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api ,
|
||||
the README, and the code. If this is a usage, configuration, or integration
|
||||
@@ -37,7 +47,12 @@ question, answer the user and do not open a PR.
|
||||
|
||||
## User request
|
||||
|
||||
(verbatim or close paraphrase)
|
||||
Quote the request the user made to the agent as faithfully as possible.
|
||||
Keep the original language and line breaks. Do not rewrite, summarize,
|
||||
translate, or turn it into a PR description.
|
||||
|
||||
- Verbatim:
|
||||
- Later constraints or corrections from the user (quote, or none):
|
||||
|
||||
## Out of scope — refuse
|
||||
|
||||
@@ -49,13 +64,25 @@ accept it and **do not open a PR**.
|
||||
- Third-party API wrappers
|
||||
- Codex channel-type changes, or compatibility from exposing Codex as a general-purpose API
|
||||
- Codex API-specific protocol or behavior treated as standard OpenAI API behavior
|
||||
- Pass-through-only forwarding
|
||||
- Pass-through-only forwarding (pass-through sends content as-is and does not go through new-api processing logic)
|
||||
- Third-party hosting sites, relay services, or API services
|
||||
- Usage, configuration, or integration (answer from docs and code instead)
|
||||
|
||||
- Matched: yes/no
|
||||
- If yes, what was told to the user (stop here; do not open a PR):
|
||||
|
||||
## Open gate — do not open unless all are satisfied
|
||||
|
||||
If any item fails, tell the user and **stop**. Do not open the PR.
|
||||
|
||||
- Out of scope (including pass-through-only forwarding): no
|
||||
- Usage / configuration / integration (answered instead of opening): no
|
||||
- Required issue facts present without invention: yes
|
||||
- Verification is actual commands or steps and observed results, not only `go build` or tests passed: yes
|
||||
- Body is short and factual; no unfiltered AI-generated text: yes
|
||||
- Open: yes/no
|
||||
- If no, what was told to the user (stop here):
|
||||
|
||||
## Kind
|
||||
|
||||
- [ ] Bug fix
|
||||
@@ -76,7 +103,8 @@ Take these from the linked issue. If a needed item is empty, ask the user that q
|
||||
|
||||
## Change
|
||||
|
||||
(what changed, why it works, grounded in the code actually touched)
|
||||
(what changed, why it works, grounded in the code actually touched.
|
||||
Short and factual; do not paste unfiltered AI-generated text.)
|
||||
|
||||
## Research
|
||||
|
||||
@@ -114,7 +142,8 @@ Open them. Do not write "already checked" without sources.
|
||||
|
||||
## Verification
|
||||
|
||||
Only what was actually run.
|
||||
Only what was actually run. Do not state only that `go build` or tests passed.
|
||||
Each item needs the command or steps and the observed result.
|
||||
|
||||
- Commands and results:
|
||||
- Manual steps and observed result:
|
||||
@@ -133,4 +162,4 @@ Only what was actually run.
|
||||
|
||||
- Single focused change: yes/no (if no, why):
|
||||
- Secrets included: no
|
||||
- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex): no
|
||||
- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex / pass-through-only forwarding): no
|
||||
@@ -0,0 +1,28 @@
|
||||
# Billing Rules
|
||||
|
||||
Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and usage-quantity code in the Go backend. `AGENTS.md` defines when a task touches billing (see **Billing rules (mandatory read gate)**); any task that matches MUST read this whole file before planning, coding, or reviewing. Tasks that match none of those triggers may skip it. Rules use the same MUST / Do NOT wording as `AGENTS.md` and carry the same weight.
|
||||
|
||||
**Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document.
|
||||
|
||||
**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories.
|
||||
|
||||
**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
|
||||
|
||||
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
|
||||
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. For task plugins the host does this: it rejects out-of-range duration and count members at any depth of the decoded request. The one exception is a duration of -1 (the vendor picks the length) for plugins that declare `duration-auto@1`; they must reserve the model's longest duration and settle on the usage the vendor reports.
|
||||
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
|
||||
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
|
||||
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
|
||||
- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards.
|
||||
- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
|
||||
- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory.
|
||||
- Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style.
|
||||
|
||||
**Upstream image quantity settlement:** The billable image count may be lowered or raised from the upstream response only through `RelayInfo.UpdateImageCount` on the HTTP image relay, or through the `image_count` usage fact a task plugin returns on completion; a zero or out-of-range count leaves the reserved request quantity in place. Derive the count from image payloads, never from JSON structure:
|
||||
|
||||
- Do NOT count `data` with gjson `data.#`, `len(data)`, or any element count. Providers behind OpenAI-compatible channels return non-standard shapes: `data` as a single object, one image split into a `url` entry and a `b64_json` entry, or entries carrying only `revised_prompt`.
|
||||
- An object-shaped `data` is one image when it has a non-empty `url` or `b64_json`, otherwise zero. For arrays the count is `max(entries with non-empty url, entries with non-empty b64_json)`: a standard response uses one `response_format` so this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation is `openaiImageResponseCount` in `relay/channel/openai/relay_image.go`; reuse it (or the same rule) for any channel that forwards or rebuilds OpenAI-format image responses. The xAI adaptor already inherits it through `OpenaiImageHandler`.
|
||||
- When an OpenAI-format JSON body is re-emitted as SSE, forward one `image_generation.completed` event per image payload using the same shape handling (object → one event, payload-less entries dropped). The client must never receive only `[DONE]` while being charged for images.
|
||||
- Real SSE streams count `image_generation.completed` / `image_edit.completed` events and only trust a lower count when upstream finished the stream (`done` / `eof`); a client abort must not reduce the charge below the requested quantity.
|
||||
- Ali (Bailian) image models are served by the `alibaba` task plugin through the `openai_image` host protocol. Its `imageUsage` reads `usage.image_count`, then `usage.output_image_count` (Qwen-Image-3.0), and otherwise counts image payloads flattened across every `choices[].message.content[]` entry and `results[]`; payload-less parts bill nothing, and a fractional, negative, oversized, or payload-inconsistent upstream count keeps the reserved request quantity. Any other channel with its own usage field applies the same order: trust the bounded upstream count first, then count payloads. Channels that rebuild responses from typed structs count the typed slice; none of them may read `data.#`.
|
||||
- Regression cases for object, split, and payload-less shapes belong in `relay/channel/openai/image_stream_test.go` (fixed-price, tiered-expression, and JSON-as-SSE forwarding tables). Extend those tables instead of adding new files.
|
||||
@@ -9,14 +9,23 @@ body:
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## 提交前必读(请勿删除本节)
|
||||
|
||||
- type: textarea
|
||||
id: read-first
|
||||
attributes:
|
||||
label: 提交前必读
|
||||
description: GitHub 的 markdown 说明提交后不会进入 issue。本节会写入正文,请勿删除或清空。
|
||||
value: |
|
||||
- 文档:https://docs.newapi.ai/
|
||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||
- 转发类问题必须先确认问题发生在 new-api 这一层:请使用等价的脱敏请求分别直连上游和经过 new-api,对比响应状态、错误体与服务端日志。透传模式或未经对比、仅展示上游报错的反馈不接受 issue。
|
||||
- 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。
|
||||
- 请以简练语言陈述事实与证据。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本。
|
||||
- 转发类问题必须先确认问题发生在 new-api 这一层:请使用等价的脱敏请求分别直连上游和经过 new-api,对比响应状态、错误体与服务端日志。未经对比、仅展示上游报错的反馈不接受 issue。
|
||||
- 不接受 Coding Plan、逆向渠道、第三方封装接口,以及将 Codex 接口反代为通用 API 后产生的兼容性问题;Codex API 自身特有的协议或行为也不应被当作标准 OpenAI API 行为,相关问题请先向渠道或接口提供方确认。
|
||||
- 本项目不提供官方托管服务,也不为第三方托管站、中转站或 API 服务提供技术支持;此类问题请联系对应运营方。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;多次提交未经人工过滤的大段 AI 文本,或重复恶意提交者,可能会被 block。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment-source
|
||||
@@ -52,11 +61,13 @@ body:
|
||||
required: true
|
||||
- label: "**非第三方服务:** 我确认这不是第三方托管站、中转站、API 服务或未经本仓库原版验证的 fork 所独有的问题;第三方实例问题应联系其运营方。"
|
||||
required: true
|
||||
- label: "**非透传:** 我确认相关渠道未开启透传。开启透传会直接发送内容,不会经过 new-api 的逻辑处理,此类问题不接受 issue。"
|
||||
required: true
|
||||
- label: "**问题归属:** 我已提供足以区分客户端、new-api 和上游的证据;转发问题已使用等价的脱敏请求对比直连上游与经过 new-api 的结果,确认错误由 new-api 引入或改变,而非仅原样转发上游报错。"
|
||||
required: true
|
||||
- label: "**渠道与协议边界:** 我确认问题不是由 Coding Plan、逆向渠道、第三方封装接口、Codex 反代接口,或 Codex API 自身特有行为导致;若问题最初出现于此类接口,我已在本仓库支持的标准 API 协议上复现。"
|
||||
required: true
|
||||
- label: "**维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。"
|
||||
- label: "**维护成本:** 我理解项目维护者精力有限。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本,须先提炼可复核的要点;不遵循模板要求或缺少要点、无法复核的内容可能会被无视或直接关闭。多次提交此类内容可能会被 block。"
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
|
||||
@@ -9,14 +9,23 @@ body:
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## Read This First (Do Not Remove This Section)
|
||||
|
||||
- type: textarea
|
||||
id: read-first
|
||||
attributes:
|
||||
label: Read This First
|
||||
description: GitHub markdown instructions are not included in the submitted issue. This section is written into the issue body. Do not delete or clear it.
|
||||
value: |
|
||||
- Docs: https://docs.newapi.ai/
|
||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
||||
- Relay reports must first establish that the issue occurs in the new-api layer. Use equivalent redacted requests to compare the direct upstream result with the result through new-api, including response status, error body, and server logs. Reports involving pass-through mode, or showing only an upstream error without this comparison, are not accepted.
|
||||
- Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic.
|
||||
- State facts and evidence concisely. Do not paste unfiltered AI-generated text in the issue body or in later comments.
|
||||
- Relay reports must first establish that the issue occurs in the new-api layer. Use equivalent redacted requests to compare the direct upstream result with the result through new-api, including response status, error body, and server logs. Reports that skip this comparison or only show an upstream error are not accepted.
|
||||
- Reports about Coding Plan services, reverse-engineered channels, third-party API wrappers, or compatibility issues caused by exposing a Codex endpoint through a reverse proxy as a general-purpose API are not accepted. Protocols or behavior specific to the Codex API should not be treated as standard OpenAI API behavior; confirm such issues with the channel or API provider first.
|
||||
- This project does not operate an official hosted service or provide technical support for third-party hosting sites, relay services, or API services. Contact the relevant operator for those issues.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated submissions of unfiltered AI-generated text, or repeated abusive submissions, may result in a block.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment-source
|
||||
@@ -52,11 +61,13 @@ body:
|
||||
required: true
|
||||
- label: "**Not a third-party service:** I confirm that this issue is not exclusive to a third-party hosting site, relay, API service, or fork that has not been verified against the unmodified repository. Third-party instance issues must be reported to their operator."
|
||||
required: true
|
||||
- label: "**Not pass-through:** I confirm that pass-through is not enabled on the relevant channel. Pass-through sends content as-is and does not go through new-api processing logic; such reports are not accepted."
|
||||
required: true
|
||||
- label: "**Issue attribution:** I have provided evidence that distinguishes the client, new-api, and upstream layers. For relay issues, I compared equivalent redacted requests sent directly upstream and through new-api, confirming that new-api introduces or changes the error rather than merely forwarding the upstream error unchanged."
|
||||
required: true
|
||||
- label: "**Channel and protocol boundary:** I confirm that the issue is not caused by a Coding Plan service, reverse-engineered channel, third-party API wrapper, Codex reverse-proxy endpoint, or behavior specific to the Codex API. If first observed through such an interface, I have reproduced it using a standard API protocol supported by this repository."
|
||||
required: true
|
||||
- label: "**Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly."
|
||||
- label: "**Maintainer time:** I understand that maintainers have limited time. Do not paste unfiltered AI-generated text in the issue body or in later comments; extract the points needed for review first. Submissions that do not follow this template, or that omit the necessary points and cannot be reviewed, may be ignored or closed. Repeated submissions of this kind may result in a block."
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
|
||||
@@ -9,19 +9,28 @@ body:
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## 提交前必读(请勿删除本节)
|
||||
|
||||
- type: textarea
|
||||
id: read-first
|
||||
attributes:
|
||||
label: 提交前必读
|
||||
description: GitHub 的 markdown 说明提交后不会进入 issue。本节会写入正文,请勿删除或清空。
|
||||
value: |
|
||||
- 文档:https://docs.newapi.ai/
|
||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
|
||||
- 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。
|
||||
- 请以简练语言陈述事实与证据。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本。
|
||||
- 不接受 Coding Plan、逆向渠道、第三方封装接口,以及将 Codex 接口反代为通用 API 后产生的兼容性问题;Codex API 自身特有的协议或行为也不应被当作标准 OpenAI API 行为,相关问题请先向渠道或接口提供方确认。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||
- 本项目不提供官方托管服务,也不为第三方托管站、中转站或 API 服务提供技术支持;此类问题请联系对应运营方。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;多次提交未经人工过滤的大段 AI 文本,或重复恶意提交者,可能会被 block。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 您当前的 newapi 版本
|
||||
description: 请填写,例如:`v1.0.0`
|
||||
description: 请填写准确的版本号、commit 或镜像标签,例如 `v1.0.0`;`latest`、`unknown` 等无法定位代码的内容不视为有效版本信息。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -34,9 +43,17 @@ body:
|
||||
required: true
|
||||
- label: "**提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求。"
|
||||
required: true
|
||||
- label: "**受支持版本:** 我已提供准确的版本号、commit 或镜像标签(不是 `latest` 或 `unknown`),并确认这是针对本仓库未修改的受支持版本提出的功能需求。"
|
||||
required: true
|
||||
- label: "**非第三方服务:** 我确认这不是第三方托管站、中转站、API 服务或未经本仓库原版验证的 fork 所独有的需求;第三方实例问题应联系其运营方。"
|
||||
required: true
|
||||
- label: "**非透传:** 我确认这不是开启透传后的转发相关反馈;透传会直接发送内容,不会经过 new-api 的逻辑处理。"
|
||||
required: true
|
||||
- label: "**渠道与协议边界:** 我确认这不是 Coding Plan、逆向渠道、第三方封装接口、Codex 反代接口,或把 Codex API 自身特有行为当作标准 OpenAI API 行为的功能请求;若需求最初出现于此类接口,我已基于本仓库支持的标准 API 协议提出。"
|
||||
required: true
|
||||
- label: "**模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。"
|
||||
required: true
|
||||
- label: "**维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。"
|
||||
- label: "**维护成本:** 我理解项目维护者精力有限。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本,须先提炼可复核的要点;不遵循模板要求或缺少要点、无法复核的内容可能会被无视或直接关闭。多次提交此类内容可能会被 block。"
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -9,19 +9,28 @@ body:
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## Read This First (Do Not Remove This Section)
|
||||
|
||||
- type: textarea
|
||||
id: read-first
|
||||
attributes:
|
||||
label: Read This First
|
||||
description: GitHub markdown instructions are not included in the submitted issue. This section is written into the issue body. Do not delete or clear it.
|
||||
value: |
|
||||
- Docs: https://docs.newapi.ai/
|
||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
||||
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
|
||||
- Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic.
|
||||
- State facts and evidence concisely. Do not paste unfiltered AI-generated text in the issue body or in later comments.
|
||||
- Reports about Coding Plan services, reverse-engineered channels, third-party API wrappers, or compatibility issues caused by exposing a Codex endpoint through a reverse proxy as a general-purpose API are not accepted. Protocols or behavior specific to the Codex API should not be treated as standard OpenAI API behavior; confirm such issues with the channel or API provider first.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
|
||||
- This project does not provide official hosting and does not support third-party hosting sites, relays, or API services; contact their operator instead.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated submissions of unfiltered AI-generated text, or repeated abusive submissions, may result in a block.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Your current newapi version
|
||||
description: "Please fill this in, for example: `v1.0.0`"
|
||||
description: "Provide an exact version, commit, or image tag, for example `v1.0.0`. Values such as `latest` or `unknown` that cannot locate the code are not valid version information."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -34,9 +43,17 @@ body:
|
||||
required: true
|
||||
- label: "**Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question, and that the current version cannot meet my needs."
|
||||
required: true
|
||||
- label: "**Supported version:** I have provided an exact version, commit, or image tag (not `latest` or `unknown`) and confirm this feature request is for an unmodified, supported version from this repository."
|
||||
required: true
|
||||
- label: "**Not a third-party service:** I confirm that this request is not exclusive to a third-party hosting site, relay, API service, or fork that has not been verified against the unmodified repository. Third-party instance issues must be reported to their operator."
|
||||
required: true
|
||||
- label: "**Not pass-through:** I confirm this is not a forwarding report after enabling pass-through; pass-through sends content as-is and does not go through new-api processing logic."
|
||||
required: true
|
||||
- label: "**Channel and protocol boundary:** I confirm this is not a feature request for a Coding Plan service, reverse-engineered channel, third-party API wrapper, Codex reverse-proxy endpoint, or treating Codex API-specific behavior as standard OpenAI API behavior. If first observed through such an interface, I have based the request on a standard API protocol supported by this repository."
|
||||
required: true
|
||||
- label: "**Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested."
|
||||
required: true
|
||||
- label: "**Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly."
|
||||
- label: "**Maintainer time:** I understand that maintainers have limited time. Do not paste unfiltered AI-generated text in the issue body or in later comments; extract the points needed for review first. Submissions that do not follow this template, or that omit the necessary points and cannot be reviewed, may be ignored or closed. Repeated submissions of this kind may result in a block."
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -7,7 +7,8 @@ English template: `.github/PULL_REQUEST_TEMPLATE/en.md`
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - 描述可用 AI 辅助。提交前请审阅全文,并**声明对其负责**,避免未经核对的直接粘贴。
|
||||
> - 描述可用 AI 辅助。提交前请审阅并精炼全文,仅保留维护者需要复核的要点,并**声明对其负责**。PR 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本;多次提交此类内容可能会被 block。
|
||||
> - 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。
|
||||
> - 请按本模板填写后再提交。
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
@@ -24,17 +25,18 @@ English template: `.github/PULL_REQUEST_TEMPLATE/en.md`
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述做了什么、为什么生效。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。)
|
||||
(简述做了什么、为什么生效。不得直接粘贴未经人工过滤的大段 AI 文本。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。)
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请写明如何验证:实际步骤与观察结果。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。)
|
||||
(请写明如何验证:实际命令或步骤,以及观察结果。仅声明 `go build` 通过或测试通过,不视为有效证明。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅全部内容,并声明对其准确性与完整性负责。
|
||||
- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅并精炼全文,仅保留可复核的要点,并声明对其准确性与完整性负责。PR 正文及后续评论均未直接粘贴未经人工过滤的大段 AI 文本。
|
||||
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
|
||||
- [ ] **新功能关联 Issue:** 若此 PR 标记为 `New feature`,我已关联对应 Issue;若尚无 Issue,我已先自行创建。
|
||||
- [ ] **事前沟通:** 若改动较大或涉及方向性变更,已在关联 Issue 中与维护者沟通并达成一致。
|
||||
- [ ] **功能范围:** 本 PR 不是 Coding Plan、逆向渠道、第三方封装接口,也不是对 Codex 渠道类型的改动。
|
||||
- [ ] **非透传:** 本 PR 不是针对开启透传后的转发行为;透传会直接发送内容,不会经过 new-api 的逻辑处理。
|
||||
- [ ] **范围聚焦:** 本 PR 为一项聚焦改动,未包含无关代码。
|
||||
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核。
|
||||
- [ ] **本地验证:** 已按变更路径实际验证,并写明命令与观察结果。仅声明 `go build` 通过或测试通过,不视为有效证明。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
@@ -7,7 +7,8 @@ Chinese template: `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - AI-assisted descriptions are welcome. Please review the full text before submitting and **take responsibility** for it. Avoid pasting unreviewed content.
|
||||
> - AI-assisted descriptions are welcome. Review and condense the full text before submitting, keep only the points maintainers need to re-check, and **take responsibility** for it. Do not paste unfiltered AI-generated text in the PR body or in later comments. Repeated submissions of this kind may result in a block.
|
||||
> - Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic.
|
||||
> - Please complete this template before submitting.
|
||||
|
||||
## Related Issue
|
||||
@@ -24,17 +25,18 @@ Chinese template: `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- [ ] Documentation
|
||||
|
||||
## Description
|
||||
(Briefly describe what changed and why it works. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.)
|
||||
(Briefly describe what changed and why it works. Do not paste unfiltered AI-generated text. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.)
|
||||
|
||||
## Proof of Work
|
||||
(Please describe how this was verified: the steps run and what was observed. For UI changes, please include a screenshot or recording. For bug fixes, please describe the reproduction and the result after the fix.)
|
||||
(Record how this was verified: the actual commands or steps and the observed results. Stating only that `go build` or tests passed is not valid proof. For UI changes, include a screenshot or recording. For bug fixes, describe the reproduction and the result after the fix.)
|
||||
|
||||
## Checklist
|
||||
- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed the full content and take responsibility for its accuracy and completeness.
|
||||
- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed and condensed the full text, retained only the points needed for review, and take responsibility for its accuracy and completeness. I have not pasted unfiltered AI-generated text in the PR body or in later comments.
|
||||
- [ ] **Not a duplicate:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues) and [PRs](https://github.com/QuantumNous/new-api/pulls) and confirmed this is not a duplicate.
|
||||
- [ ] **Feature issue:** If this PR is a New feature, I have linked a corresponding Issue; if none existed, I created one first.
|
||||
- [ ] **Prior discussion:** If this is a large or directional change, I have discussed it with maintainers in the linked Issue and reached agreement.
|
||||
- [ ] **Scope:** This PR is not a Coding Plan, reverse-engineered channel, third-party API wrapper, or a change to the Codex channel type.
|
||||
- [ ] **Not pass-through:** This PR is not about forwarding behavior after enabling pass-through; pass-through sends content as-is and does not go through new-api processing logic.
|
||||
- [ ] **Focused change:** This PR is a single focused change and does not include unrelated code.
|
||||
- [ ] **Local verification:** I have run tests or manually verified locally so maintainers can re-check from this evidence.
|
||||
- [ ] **Local verification:** I verified the changed path and recorded the commands and observed results. Stating only that `go build` or tests passed is not valid proof.
|
||||
- [ ] **Security:** This change does not include secrets and follows the project's coding guidelines.
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -11,6 +11,8 @@ concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Checkout the event commit in both jobs. A PR merge ref can disappear while
|
||||
# queued runs start, and a base branch can advance after the merge event.
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend vet, build, and test
|
||||
@@ -25,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.action == 'closed' && github.event.pull_request.base.ref || github.ref }}
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
@@ -34,6 +36,7 @@ jobs:
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
relaykit/go.sum
|
||||
tokenkit/go.sum
|
||||
|
||||
- name: Create frontend embed placeholder
|
||||
run: |
|
||||
@@ -47,6 +50,10 @@ jobs:
|
||||
working-directory: relaykit
|
||||
run: go vet ./...
|
||||
|
||||
- name: Vet tokenkit independently
|
||||
working-directory: tokenkit
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build root module
|
||||
run: go build ./...
|
||||
|
||||
@@ -54,7 +61,11 @@ jobs:
|
||||
working-directory: relaykit
|
||||
run: go build ./...
|
||||
|
||||
- name: Test root and relaykit modules
|
||||
- name: Build tokenkit independently
|
||||
working-directory: tokenkit
|
||||
run: go build ./...
|
||||
|
||||
- name: Test root, relaykit, and tokenkit modules
|
||||
run: make test
|
||||
|
||||
frontend:
|
||||
@@ -71,7 +82,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.action == 'closed' && github.event.pull_request.base.ref || github.ref }}
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.25.1'
|
||||
go-version: '>=1.26'
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
@@ -55,13 +55,13 @@ jobs:
|
||||
# if: runner.os != 'Windows'
|
||||
# run: |
|
||||
# go mod download
|
||||
# go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
# go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
|
||||
- name: Build Go binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||
go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||
|
||||
- name: Update Electron version
|
||||
run: |
|
||||
|
||||
@@ -44,16 +44,16 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.25.1'
|
||||
go-version: '>=1.26'
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
- name: Build Backend (arm64)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
- name: Generate checksums
|
||||
run: sha256sum new-api-* > checksums-linux.txt
|
||||
|
||||
@@ -98,11 +98,11 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.25.1'
|
||||
go-version: '>=1.26'
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||
go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||
- name: Generate checksums
|
||||
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
|
||||
|
||||
@@ -149,11 +149,11 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.25.1'
|
||||
go-version: '>=1.26'
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
|
||||
go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
|
||||
- name: Generate checksums
|
||||
run: sha256sum new-api-*.exe > checksums-windows.txt
|
||||
|
||||
|
||||
@@ -8,18 +8,19 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Go 1.25.1 (see each module’s `go.mod`), Gin web framework, GORM v2 ORM
|
||||
- **Backend**: Go 1.26 for the root module and `tokenkit/`, Go 1.25.1 for `relaykit/` (see each module’s `go.mod`), Gin web framework, GORM v2 ORM
|
||||
- **Frontend**: React 19, TypeScript, Rsbuild 2, TanStack Router/Query/Table, Zustand, Base UI, Tailwind CSS 4
|
||||
- **Databases**: SQLite, MySQL, PostgreSQL for the primary database (all three must be supported); a separately configured log database also supports ClickHouse
|
||||
- **Cache**: Redis (go-redis) + in-memory cache
|
||||
- **Auth**: Browser sessions, API tokens and personal access tokens, JWT, WebAuthn/Passkeys, TOTP, OAuth/OIDC; Casbin authorization in `service/authz/`
|
||||
- **Extensions**: JavaScript task plugins executed by Sobek; Electron desktop wrapper
|
||||
- **Extensions**: JavaScript task plugins executed by moejs; Electron desktop wrapper
|
||||
- **Frontend package manager**: Bun (preferred over npm/yarn/pnpm)
|
||||
|
||||
## Architecture
|
||||
|
||||
- The Go gateway handles management APIs, upstream relay, billing, and background tasks across `router/`, `middleware/`, `controller/`, `service/`, `model/`, and `relay/`.
|
||||
- `relaykit/` is an independent Go module for protocol DTOs and conversions; transport, authentication, database access, and billing stay in the host.
|
||||
- `tokenkit/` is an independent Go module for text and image token counting and estimation; request parsing, file loading, and billing stay in the host.
|
||||
- JavaScript task plugins live in `plugins/tasks/`, run through `pkg/jsplugin/`, and integrate with host task polling and settlement.
|
||||
- `web/` is the React frontend (see `web/AGENTS.md`); `electron/` is the desktop wrapper.
|
||||
|
||||
@@ -27,7 +28,11 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
||||
|
||||
### Backend (`i18n/`)
|
||||
- Library: `nicksnyder/go-i18n/v2`
|
||||
- Languages: en, zh
|
||||
- Languages: en, zh-CN, zh-TW
|
||||
- Scope: text with no web console in between (errors returned to AI clients, emails, notifications), translated with `i18n.T` / `i18n.Translate`
|
||||
- Web console API messages are English source text built with `common.NewMessage` / `common.ApiErrorT` / `common.ApiSuccessT`; the frontend translates them, so add each key to all seven frontend locale files
|
||||
- Stored log content is `[]*common.Message`; a fail reason written by the gateway is a fixed English sentence that is also a key in the frontend locale files
|
||||
- Server log lines are English text passed through `common.LogText`; `DEFAULT_LANGUAGE=zh-CN` prints the Chinese text of the lines that have one in `common/log_text.zh-CN.json`, and is the language of backend messages for a reader who states none
|
||||
|
||||
### Frontend (`web/src/i18n/`)
|
||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||
@@ -72,10 +77,10 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
||||
- Remove ineffective `omitempty` tags on non-pointer struct fields only after confirming the active JSON encoder preserves the same output. Do not change field types or omission behavior as part of a style cleanup; optional relay scalar fields must still follow the pointer rules below.
|
||||
- Format modified Go files with `gofmt` and remove unused imports after these changes.
|
||||
|
||||
**relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable.
|
||||
**relaykit and tokenkit module independence:** The `relaykit/` and `tokenkit/` Go modules MUST remain independently buildable.
|
||||
|
||||
- Code under `relaykit/` MUST NOT import or depend on packages from the root `new-api` module, or rely on root-only configuration, generated files, or workspace wiring.
|
||||
- Any change affecting `relaykit/` or its public APIs MUST be verified with `cd relaykit && GOWORK=off go build ./...`; a successful root-module build is not sufficient.
|
||||
- Code under `relaykit/` or `tokenkit/` MUST NOT import or depend on packages from the root `new-api` module, or rely on root-only configuration, generated files, or workspace wiring. `tokenkit/` also MUST NOT depend on `relaykit/`.
|
||||
- Any change affecting `relaykit/`, `tokenkit/`, or their public APIs MUST be verified with `GOWORK=off go build ./...` inside that module; a successful root-module build is not sufficient.
|
||||
|
||||
**JSON package:** In the root Go module, all JSON marshal/unmarshal operations MUST use the wrapper functions in `common/json.go`:
|
||||
|
||||
@@ -115,21 +120,22 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go`
|
||||
- Preserve explicit zero values in upstream relay request DTOs: absent client JSON fields must become `nil` and be omitted, while explicit `0`, `0.0`, or `false` values must remain non-`nil` and be sent upstream.
|
||||
- Avoid non-pointer scalars with `omitempty` for optional request parameters, because zero values will be silently dropped during marshal.
|
||||
|
||||
**Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document.
|
||||
**JavaScript task plugins (mandatory):**
|
||||
|
||||
**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories.
|
||||
- Before implementing, modifying, or reviewing JavaScript task plugins or their host API/runtime, MUST read [Task Plugin API v1](docs/plugin-api/v1.md), including its description writing and translation conventions. When changing the plugin contract, also check `docs/plugin-api/v1.schema.json` and `docs/plugin-api/v1.d.ts` for consistency.
|
||||
- For numeric billing fields in `usageSchema` and `usageProfiles[].schema`, `description` MUST name the **billing subject + unit price**, because it labels the price input in the UI. For example, `image_count` uses `Image generation unit price` / `图片生成单价`, not `Generated image count` / `生成图片张数`; `seconds` uses `Video generation unit price` / `视频生成单价`. The field value remains a usage quantity, not a price.
|
||||
- Put units in `unit`. Keep protocol limits, usage sources, defaults, estimation, and settlement details in code comments or technical documentation. Descriptions must be short, equivalent across languages, and free of numeric prices and trailing punctuation. Follow the API document's separate wording rules for actions, booleans, and other enum conditions.
|
||||
- Review metadata wording explicitly before completing plugin work. These are authoring requirements; successful compilation, schema validation, or tests do not verify that descriptions follow them.
|
||||
|
||||
**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
|
||||
**Billing rules (mandatory read gate):** `.agents/rules/billing.md` holds the billing conventions (expression system, built-in pricing, safety invariants, deriving billable quantities from upstream responses). If a task touches billing as defined below, you MUST read that file in full with your file-read tool — not a grep, a partial skim, memory, or a summary — before planning, coding, or reviewing, and then follow every rule in it. A task touches billing when it meets any of these:
|
||||
|
||||
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
|
||||
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally.
|
||||
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
|
||||
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
|
||||
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
|
||||
- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards.
|
||||
- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
|
||||
- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory.
|
||||
- Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style.
|
||||
- Edits `pkg/billingexpr/`, `setting/billing_setting/`, `common/quota_math.go`, `types/price_data.go`, `relay/request_billing.go`, `relay/image_handler.go`, `relay/relay_task.go`, `relay/helper/price.go`, `relay/helper/billing_expr_request.go`, `relay/helper/valid_request.go`, `service/quota.go`, `service/text_quota.go`, `service/image_billing.go`, `service/tiered_settle.go`, `service/task_billing.go`, `service/responses_usage.go`, the billing part of `service/log_info_generate.go`, the `model/pricing*.go` / `model/model_pricing*.go` files, or the billing fields and methods of `relay/common/relay_info.go` (`PriceData`, `TieredBillingSnapshot`, `BillingImageCount`, `UpdateImageCount`).
|
||||
- Reads or writes `PriceData` / `OtherRatios`, quota pre-consume, settlement, refund, or consume-log billing fields anywhere else.
|
||||
- Derives a billable quantity or `Usage` from an upstream response or stream (image counts, seconds, tokens, task deductions) in any channel adaptor, response handler, or task plugin.
|
||||
- Validates, bounds, or forwards a request field that becomes a billing multiplier (`n`, `max_tokens`-family fields, duration, resolution or quality, batch counts), including passthrough and multipart paths.
|
||||
- Adds or changes model prices, or numeric `usageSchema` / `usageProfiles[].schema` fields in a task plugin.
|
||||
|
||||
Tasks that touch none of these (for example unrelated frontend work, authentication, database migrations, or protocol conversion that leaves usage untouched) do not need to read it.
|
||||
|
||||
**Backend test quality:** Backend tests must protect real behavior, API contracts, billing/accounting invariants, data compatibility, or regression paths.
|
||||
|
||||
@@ -145,7 +151,10 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go`
|
||||
- Avoid hand-written assertion helpers unless they encode a reusable project-specific invariant.
|
||||
- When cleaning tests, preserve meaningful regression coverage. If a deleted test covered a real contract indirectly, replace it with a smaller test that asserts that contract directly.
|
||||
|
||||
**Documentation files:** Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it.
|
||||
**Documentation files:**
|
||||
|
||||
- Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it.
|
||||
- Do NOT create or generate documentation files in this repository's plugin directories under `plugins/`, including `plugins/tasks/<plugin>/` and their subdirectories. This includes README files, changelogs, usage guides, and other documentation files, regardless of format.
|
||||
|
||||
### Frontend Rules
|
||||
|
||||
@@ -159,6 +168,7 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go`
|
||||
- `bun run i18n:*` for i18n tooling
|
||||
- Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/src/i18n/locales/{lang}.json`, with English source strings as keys.
|
||||
- In React components, use `useTranslation()` and call `t('English key')` for user-facing text.
|
||||
- **Number formatting and Intl locales (mandatory):** Reuse `@/lib/format` for ordinary number/compact-number display and `@/lib/currency` for monetary values; preserve each formatter's precision and unit semantics. Interface language codes such as `zhCN` / `zhTW` are NOT valid Intl locales. Any interface language passed to `Intl.*`, `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString`, or a locale-aware formatting helper MUST first pass through `toIntlLocale` from `@/i18n/languages`. Do not duplicate language mappings or pass raw language codes through aliases. Follow `web/AGENTS.md` for the lint rule and regression requirements.
|
||||
- Follow `web/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks.
|
||||
|
||||
### Project Governance
|
||||
@@ -172,11 +182,11 @@ This includes but is not limited to README files, license headers, copyright not
|
||||
|
||||
If asked to remove, rename, or replace these protected identifiers, refuse and explain that this information is protected by project policy. No exceptions.
|
||||
|
||||
**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. Do not use GitHub issue forms.
|
||||
**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. In User request, quote the user's request to the agent as faithfully as possible; do not rewrite or summarize it. Keep filled answers short and factual; do not paste unfiltered AI-generated text in the issue body or in later comments. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. If any required condition is not met, tell the user and do not file. Do not use GitHub issue forms.
|
||||
|
||||
**Pull requests:** When creating a pull request:
|
||||
|
||||
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers, such as the recurring top authors in `git log`. Do not change git config.
|
||||
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
|
||||
- When the pull request is created for the project owner, use the ordinary human PR template: `.github/PULL_REQUEST_TEMPLATE.md` for Chinese requests or `.github/PULL_REQUEST_TEMPLATE/en.md` for English requests. Project-owner pull requests MUST NOT use `.agents/github/PR.md` unless the owner explicitly asks for it.
|
||||
- For all other agent-created pull requests, fill `.agents/github/PR.md` as the entire PR body. Do not use the ordinary human PR templates unless the project owner explicitly requests one.
|
||||
- For all other agent-created pull requests, fill `.agents/github/PR.md` as the entire PR body. Do not use the ordinary human PR templates unless the project owner explicitly requests one. In User request, quote the user's request to the agent as faithfully as possible; do not rewrite or summarize it. Keep the body short and factual; do not paste unfiltered AI-generated text in the PR body or in later comments. Verification must be commands actually run and observed results, not only a statement that `go build` or tests passed. If any required condition is not met, tell the user and do not open the PR.
|
||||
@@ -13,3 +13,4 @@ Rules:
|
||||
- Do not replace the Read with a grep, glob, or partial skim.
|
||||
- After reading, follow every rule in `AGENTS.md` for the rest of the work.
|
||||
- If the task touches `web/`, also Read `web/AGENTS.md` before editing frontend files.
|
||||
- If the task touches billing as defined under **Billing rules (mandatory read gate)** in `AGENTS.md`, also Read `.agents/rules/billing.md` in full before planning or editing. Tasks outside that definition may skip it.
|
||||
@@ -1,20 +1,41 @@
|
||||
# 自定义版本说明
|
||||
|
||||
当前生产版本基于官方 `v1.0.0-rc.36`(`ea7cb0ba4e0f82e2bfa5e55752eb68bdf902f71b`),版本号为 `v1.0.0-rc.36-custom.2`。
|
||||
当前版本为 v1.0.0-rc.45-custom.97,基于官方 v1.0.0-rc.45。
|
||||
|
||||
包含以下生产定制:
|
||||
## 主要定制
|
||||
|
||||
- 仅展示存在可用渠道的分组;禁用、无渠道和不可用分组不进入用户筛选。
|
||||
- 固定令牌分组可配置一个备用分组;主分组无路由或发生可重试上游错误时按顺序切换,实际命中的分组用于计费和日志。
|
||||
- 固定令牌配置了备用分组时,备用分组切换优先于会话亲和规则的“失败后不重试”;上游返回可重试错误(如 503)时不再停在主分组渠道。不可重试错误、显式指定渠道和 auto 分组行为保持不变。
|
||||
- 分组状态页展示请求成功率、首字延迟、平均延迟、TPS、请求缓存和令牌缓存指标。
|
||||
- 支持按分组配置动态计费表达式,并在价格页按所选分组展示。
|
||||
- 用户侧隐藏上游错误细节,完整错误仅保留在 root 日志范围;客户端断开不计为上游失败。
|
||||
- 固定令牌支持有序分组池、备用分组、可重试错误切换及分组可用性判断;按实际命中的分组计费和记录日志。
|
||||
- 支持分组动态计费表达式、请求明细、路由过程及逐次请求结算。
|
||||
- 用户侧隐藏上游错误细节;客户端断开与真实上游故障分别处理。
|
||||
- Astra 模型指纹抽检、响应模型守卫、鹈鹕检测及检测费用结算。
|
||||
- 独立公开 uptime 页,使用 shadcn/ui,支持分组别名、隐藏分组和管理员重置当前分组检测记录。
|
||||
- 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。
|
||||
- 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。
|
||||
- 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。
|
||||
- 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。
|
||||
|
||||
生产镜像通过以下命令构建:
|
||||
## custom.97
|
||||
|
||||
```sh
|
||||
docker build --pull=false -f Dockerfile.request-status -t local/new-api:v1.0.0-rc.36-custom.2-20260911 .
|
||||
```
|
||||
- 视觉检测区分能力残缺、疑似降智和运行失败,保留各项检测的独立有效结论。
|
||||
- 按分组与模型展示吞吐量、首字时间:各自优先使用最近 30 次有效调用,缺少调用时使用测试数据。
|
||||
- 自动分组新增高吞吐优先策略;网站图标可通过构建变量配置。
|
||||
- Astra 优先分组预设从当前分组池选择,不绑定特定供应商。
|
||||
|
||||
`Dockerfile.request-status` 会执行前端类型检查、相关 Vitest 与 oxlint 检查、Go 回归测试和生产构建。
|
||||
## 从源码构建
|
||||
|
||||
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.45-custom.97 .
|
||||
|
||||
此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。
|
||||
|
||||
构建需要 Bun 和 Go 1.26;Dockerfile.local-release 包含完整的前端与后端构建过程。
|
||||
|
||||
## 运行与升级
|
||||
|
||||
使用现有外部 PostgreSQL 和 Valkey,连接信息通过部署环境提供。仓库不包含生产凭据、数据库内容或线上 Compose 配置。
|
||||
|
||||
升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。
|
||||
|
||||
|
||||
## custom.44 current availability
|
||||
|
||||
最近 15 分钟内,以最新完成的真实用户请求结果决定分组状态:成功为正常,上游服务失败为异常;抽检不覆盖这段时间的真实使用结果。客户端取消、参数错误和本地额度不足不改变分组健康状态。没有近期真实请求时使用最近一次独立探活,超过 30 分钟未探活显示未知。禁用分组保持不可用。指纹、鹈鹕和小时请求成功率保留为历史统计,不直接决定当前状态。正常与性能下降分组一起优先排列,同类中 Astra 优先。
|
||||
@@ -7,7 +7,7 @@ COPY ./web ./
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
FROM golang:1.26.10-alpine@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0 GOWORK=off
|
||||
|
||||
ARG TARGETOS
|
||||
@@ -18,9 +18,10 @@ ENV GOEXPERIMENT=greenteagc
|
||||
WORKDIR /build
|
||||
|
||||
ADD go.mod go.sum ./
|
||||
# relaykit is a local submodule referenced via replace; its go.mod must be
|
||||
# present for go mod download to resolve the main module graph.
|
||||
# relaykit and tokenkit are local submodules referenced via replace; their
|
||||
# go.mod files must be present for go mod download to resolve the main module graph.
|
||||
ADD relaykit/go.mod ./relaykit/go.mod
|
||||
ADD tokenkit/go.mod ./tokenkit/go.mod
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
@@ -28,7 +29,7 @@ COPY --from=builder /build/web/dist ./web/dist
|
||||
RUN go test ./service -run TestAppendStreamStatusTreatsClientGoneAsDisplayNormal
|
||||
RUN go test ./controller -run 'TestClientFacing(OpenAIErrorHidesUpstreamDetails|ClaudeErrorHidesUpstreamDetails|ErrorKeepsLocalValidationMessage)'
|
||||
RUN go test ./controller -run TestPublicUpstreamErrorContent
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
RUN go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Backend-only build for frontend development
|
||||
# Skips frontend build and uses a placeholder for //go:embed web/dist
|
||||
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
FROM golang:1.26.10-alpine@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder
|
||||
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
ARG TARGETOS
|
||||
@@ -12,9 +12,10 @@ ENV GOEXPERIMENT=greenteagc
|
||||
WORKDIR /build
|
||||
|
||||
ADD go.mod go.sum ./
|
||||
# relaykit is a local submodule referenced via replace; its go.mod must be
|
||||
# present for go mod download to resolve the main module graph.
|
||||
# relaykit and tokenkit are local submodules referenced via replace; their
|
||||
# go.mod files must be present for go mod download to resolve the main module graph.
|
||||
ADD relaykit/go.mod ./relaykit/go.mod
|
||||
ADD tokenkit/go.mod ./tokenkit/go.mod
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM oven/bun:1.4.0 AS webbuild
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY web ./
|
||||
COPY VERSION /build/VERSION
|
||||
RUN bun run typecheck
|
||||
RUN bun run test src/features/pricing/__tests__/group-pricing.test.ts src/features/pricing/__tests__/dynamic-price.test.ts
|
||||
RUN bunx oxlint -c .oxlintrc.json src/features/pricing/types.ts src/features/pricing/lib/dynamic-price.ts src/features/pricing/components/model-card.tsx src/features/pricing/components/model-details.tsx src/features/pricing/__tests__/group-pricing.test.ts
|
||||
RUN VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM local/new-api:group-cache-status-tests-20260905 AS build
|
||||
ENV CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
COPY relaykit/go.mod ./relaykit/go.mod
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=webbuild /build/web/dist ./web/dist
|
||||
RUN go test ./relay/helper ./setting/billing_setting ./controller ./service -run 'TestGroupModelPricing|TestModelPriceHelperTiered|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacing|TestPublicUpstreamErrorContent'
|
||||
RUN go build -ldflags "-s -w -X github.com/QuantumNous/new-api/common.Version=$(cat VERSION)" -o /new-api
|
||||
|
||||
FROM local/new-api:v1.0.0-rc.30-group-cache-status-20260906-r4
|
||||
COPY --from=build /new-api /new-api
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM oven/bun:1.4.0 AS web
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
RUN VITE_REACT_APP_FAVICON=/custom-site-icon.png bun run build
|
||||
|
||||
FROM golang:1.26.10@sha256:672c944cfd07599b5f1bb179869270cdc94c6254e41db4d431259693cb286b82 AS build
|
||||
ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
COPY --from=web /build/web/dist ./web/dist
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -buildvcs=false -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& update-ca-certificates
|
||||
COPY --from=build /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.45-custom.97"
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -1,40 +0,0 @@
|
||||
FROM oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS webdeps
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM node:24-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81 AS webbuild
|
||||
WORKDIR /build/web
|
||||
COPY --from=webdeps /build/web/node_modules ./node_modules
|
||||
COPY web ./
|
||||
COPY VERSION /build/VERSION
|
||||
RUN npm run typecheck
|
||||
RUN node node_modules/vitest/vitest.mjs run src/features/group-status/__tests__ src/features/pricing/__tests__/group-pricing.test.ts src/features/keys/lib/__tests__/auto-group-form.test.ts src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx
|
||||
RUN npx --no-install oxlint -c .oxlintrc.json src/features/group-status src/features/dashboard/types.ts src/features/keys/components/api-keys-mutate-drawer.tsx src/features/keys/lib/api-key-form.ts src/features/keys/types.ts
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) npm run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS build
|
||||
ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
|
||||
WORKDIR /build
|
||||
ADD go.mod go.sum ./
|
||||
ADD relaykit/go.mod ./relaykit/go.mod
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=webbuild /build/web/dist ./web/dist
|
||||
RUN go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1
|
||||
RUN go test ./controller ./service -run 'TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode' -count=1
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& update-ca-certificates
|
||||
COPY --from=build /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
|
||||
**An AI gateway for models, applications, and agents**
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">中文</a> |
|
||||
<strong>English</strong> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<a href="./README.zh_CN.md">简体中文</a> |
|
||||
<a href="./README.zh_TW.md">繁體中文</a> |
|
||||
<strong>English</strong> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
@@ -45,26 +46,54 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-quick-start">Quick Start</a> •
|
||||
<a href="#-key-features">Key Features</a> •
|
||||
<a href="#-deployment">Deployment</a> •
|
||||
<a href="#-documentation">Documentation</a> •
|
||||
<a href="#-help-support">Help</a>
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#capabilities">Capabilities</a> •
|
||||
<a href="#quick-start">Quick start</a> •
|
||||
<a href="#deployment">Deployment</a> •
|
||||
<a href="#development">Development</a> •
|
||||
<a href="#documentation">Documentation</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Project Description
|
||||
|
||||
> [!NOTE]
|
||||
New API is a self-hosted AI gateway for applications, agents, and teams. Connect upstream model services, expose a consistent API to your clients, and manage routing, access, usage, and costs in one place.
|
||||
|
||||
Use it to share authorized model access across a team, switch providers without configuring every client again, or operate a private multi-model service with a web console. Upstreams include OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen, and other compatible services.
|
||||
|
||||
> [!NOTE]
|
||||
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
||||
|
||||
> [!IMPORTANT]
|
||||
<!-- -->
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
---
|
||||
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## Screenshots
|
||||
|
||||
Accounts, channels, plugin installation states, usage, prices, and costs shown below are simulated data. The marketplace displays the official plugin catalog. Click an image to view it at full size.
|
||||
|
||||
| Usage dashboard | Plugin marketplace |
|
||||
| --- | --- |
|
||||
| [](assets/screenshots/dashboard.en.jpg) | [](assets/screenshots/plugin-marketplace.en.jpg) |
|
||||
| **Model square** | **Usage logs** |
|
||||
| [](assets/screenshots/models.en.jpg) | [](assets/screenshots/usage-logs.en.jpg) |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Trusted Partners
|
||||
@@ -80,9 +109,6 @@
|
||||
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
|
||||
</a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||
</a>
|
||||
<a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a>
|
||||
@@ -107,324 +133,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
## Capabilities
|
||||
|
||||
| Area | What you can do |
|
||||
| --- | --- |
|
||||
| Model access | Use OpenAI Chat Completions, Responses, Anthropic Messages, and Gemini APIs; stream responses and use tools, reasoning, and multimodal inputs where supported |
|
||||
| Routing | Configure model mappings, channel priorities and weights, retries, channel affinity, and multiple upstream keys |
|
||||
| Usage and costs | Manage quotas, subscriptions, usage logs, cache accounting, and expression-based pricing for different usage tiers |
|
||||
| Access control | Manage users, groups, fine-grained permissions, and API key restrictions; use OAuth/OIDC, passkeys, two-factor authentication, and login session management |
|
||||
| Asynchronous tasks | Extend image, video, and other task APIs with JavaScript plugins, including task status and output retrieval |
|
||||
| Web console | Configure channels and models, inspect usage and audit logs, and try models in the playground; available in English, Simplified Chinese, Traditional Chinese, French, Japanese, Russian, and Vietnamese |
|
||||
|
||||
### Protocols and endpoints
|
||||
|
||||
| Interface | Common endpoints |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (WebSocket upgrade) |
|
||||
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
|
||||
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
|
||||
| Task plugins | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, plus routes declared by each plugin |
|
||||
|
||||
[RelayKit](./relaykit/README.md) provides request, response, and streaming conversion between the four text protocols. Available features depend on the channel, upstream model, and conversion path; protocol-specific tools and fields may not map exactly. WebSocket support also requires a compatible upstream and channel configuration.
|
||||
|
||||
This README describes the current source tree. Check the release notes for the version you deploy.
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## Quick start
|
||||
|
||||
### Try locally with Docker
|
||||
|
||||
This starts a single instance with SQLite and binds it to localhost:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) and complete the setup wizard to create the administrator account. The `data` directory persists the SQLite database across container replacements.
|
||||
|
||||
### Make your first request
|
||||
|
||||
1. Add a channel with your upstream API key, available models, and group assignment; run a channel test.
|
||||
2. Configure model pricing and ensure the user has quota or a valid subscription.
|
||||
3. Create an API key in the console with access to the same group and models.
|
||||
4. Set your client's base URL to `http://localhost:3000/v1` for OpenAI-compatible clients and use the **New API-issued key**.
|
||||
|
||||
Set `NEW_API_KEY` in your shell to that key. List the models accessible to it:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
Then call Responses, replacing `your-enabled-model` with an enabled model that supports this interface:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose
|
||||
|
||||
The repository's [Compose configuration](./docker-compose.yml) starts **New API + PostgreSQL + Redis** by default. It also contains examples for MySQL and a separate ClickHouse log database.
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Edit docker-compose.yml configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start the service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Using Docker Commands</strong></summary>
|
||||
Before starting, edit `docker-compose.yml`: replace the database and Redis example passwords in both the services and connection strings, and set a persistent random `SESSION_SECRET` (generate one with `openssl rand -hex 32`). For an HTTPS console, configure `SESSION_COOKIE_SECURE=true` and `SESSION_COOKIE_TRUSTED_URL` with its exact public HTTPS origin.
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# Using SQLite (default)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# Using MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
|
||||
### Storage and configuration
|
||||
|
||||
</details>
|
||||
| Component | Options |
|
||||
| --- | --- |
|
||||
| Main database | SQLite, MySQL ≥ 5.7.8, or PostgreSQL ≥ 9.6 |
|
||||
| Separate log database | Configure with `LOG_SQL_DSN`; also supports ClickHouse |
|
||||
| Cache | Optional Redis plus in-memory caching; use shared Redis when application nodes need shared rate limits |
|
||||
| Container platforms | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | Main database connection; unset uses SQLite |
|
||||
| `LOG_SQL_DSN` | Optional separate log database connection |
|
||||
| `REDIS_CONN_STRING` | Redis connection string |
|
||||
| `SESSION_SECRET` | Persistent authentication secret; all nodes must use the same value |
|
||||
| `CRYPTO_SECRET` | Defaults to `SESSION_SECRET`; nodes sharing Redis must use the same effective value |
|
||||
| `SESSION_COOKIE_SECURE` | Set to `true` for an HTTPS console; enables Secure refresh cookies and strict refresh/logout origin checks |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Required in Secure mode: comma-separated exact HTTPS origins, without paths or wildcards; leave unset for local HTTP |
|
||||
| `TRUSTED_PROXIES` | Trusted reverse-proxy IPs/CIDRs, or `none`; explicitly configure for your network |
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
See the [environment example](./.env.example), [environment reference](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables), and [authentication and session guide](./docs/authentication.md) for full configuration. Configure container variables in Compose's `environment` or `env_file`; copying `.env.example` alone does not inject variables into the container.
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
For production, put the console behind HTTPS and configure your reverse proxy for streaming and WebSocket upgrades. Persist and back up the database and mounted data. Multi-node deployments must share the main database and authentication secrets; separate Redis instances or in-memory rate limiters count limits independently per node. The session guide describes propagation behavior for each topology.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
Pin an image version from [Releases](https://github.com/QuantumNous/new-api/releases), review its upgrade notes, and back up before upgrading. The `latest` tag follows published builds and can change; migrations and compatibility must be assessed for your existing installation.
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 Documentation
|
||||
## Development and extensions
|
||||
|
||||
<div align="center">
|
||||
The backend uses Go and Gin. The web console uses React 19, TypeScript, Rsbuild, TanStack, and Tailwind CSS 4. Use Bun for frontend dependencies and scripts; see [go.mod](./go.mod) for the Go language baseline and [Dockerfile](./Dockerfile) for the container build toolchain.
|
||||
|
||||
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**Quick Navigation:**
|
||||
|
||||
| Category | Link |
|
||||
|------|------|
|
||||
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
|
||||
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
|
||||
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
|
||||
|
||||
### 🎨 Core Functions
|
||||
|
||||
| Feature | Description |
|
||||
|------|------|
|
||||
| 🎨 New UI | Modern user interface design |
|
||||
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
|
||||
| 🔄 Data Compatibility | Fully compatible with the original One API database |
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
- 😈 Discord authorization login
|
||||
- 🤖 LinuxDO authorization login
|
||||
- 📱 Telegram authorization login
|
||||
- 🔑 OIDC unified authentication
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
**API Format Support:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
|
||||
|
||||
**Intelligent Routing:**
|
||||
- ⚖️ Channel weighted random
|
||||
- 🔄 Automatic retry on failure
|
||||
- 🚦 User-level model rate limiting
|
||||
|
||||
**Format Conversion:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
|
||||
- 🔄 **Thinking-to-content functionality**
|
||||
|
||||
**Reasoning Effort Support:**
|
||||
|
||||
<details>
|
||||
<summary>View detailed configuration</summary>
|
||||
|
||||
**OpenAI series models:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude thinking models:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
|
||||
|
||||
**Google Gemini series models:**
|
||||
- `gemini-2.5-flash-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-flash-nothinking` - Disable thinking mode
|
||||
- `gemini-2.5-pro-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
|
||||
- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
<details>
|
||||
<summary>View complete interface list</summary>
|
||||
|
||||
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
|
||||
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
|
||||
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
|
||||
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
|
||||
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
|
||||
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||
- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
> [!TIP]
|
||||
> **Latest Docker image:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 Deployment Requirements
|
||||
|
||||
| Component | Requirement |
|
||||
|------|------|
|
||||
| **Local database** | SQLite (Docker must mount `/data` directory)|
|
||||
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
|
||||
| **Container engine** | Docker / Docker Compose |
|
||||
| **System architecture** | 64-bit only (amd64 / arm64); 32-bit systems are not supported |
|
||||
|
||||
### ⚙️ Environment Variable Configuration
|
||||
|
||||
<details>
|
||||
<summary>Common environment variable configuration</summary>
|
||||
|
||||
| Variable Name | Description | Default Value |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
|
||||
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
|
||||
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
|
||||
| `SQL_DSN` | Database connection string | - |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | - |
|
||||
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
|
||||
| `PYROSCOPE_URL` | Pyroscope server address | - |
|
||||
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
|
||||
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
|
||||
|
||||
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 Deployment Methods
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
|
||||
Build the frontend before starting the backend, which embeds `web/dist`:
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Edit configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start service
|
||||
docker-compose up -d
|
||||
# Repository root
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
In a second terminal, start the frontend development server:
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 2: Docker Commands</strong></summary>
|
||||
|
||||
**Using SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**Using MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
Open [http://localhost:5173](http://localhost:5173); the development server proxies API requests to the backend on port 3000. For a containerized development backend, see [docker-compose.dev.yml](./docker-compose.dev.yml) and the `make dev` target in [makefile](./makefile).
|
||||
|
||||
> **💡 Path explanation:**
|
||||
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
|
||||
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
|
||||
| Location | Responsibility |
|
||||
| --- | --- |
|
||||
| `router/`, `middleware/`, `controller/` | HTTP routes, access checks, and API handlers |
|
||||
| `relay/` | Upstream adapters and request routing |
|
||||
| `service/`, `model/` | Business logic and persistence |
|
||||
| [relaykit/](./relaykit/README.md) | Independently buildable Go module for protocol DTOs and conversions |
|
||||
| [plugins/tasks/](./plugins/tasks/) | JavaScript task plugins; see [Task Plugin API v1](./docs/plugin-api/v1.md) for authoring and host boundaries |
|
||||
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
|
||||
|
||||
</details>
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun run typecheck`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 3: BaoTa Panel</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. Install BaoTa Panel (≥ 9.2.0 version)
|
||||
2. Search for **New-API** in the application store
|
||||
3. One-click installation
|
||||
## Documentation and community
|
||||
|
||||
📖 [Tutorial with images](./docs/BT.md)
|
||||
| Resource | Link |
|
||||
| --- | --- |
|
||||
| Official documentation | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [API reference](https://docs.newapi.ai/en/docs/api) |
|
||||
| Project exploration | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| Questions and discussion | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Community](https://docs.newapi.ai/en/docs/support/community-interaction) |
|
||||
| Bugs and feature requests | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| Security reports | Follow the [security policy](./.github/SECURITY.md) for private reporting |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ Multi-machine Deployment Considerations
|
||||
|
||||
> [!WARNING]
|
||||
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
|
||||
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
|
||||
|
||||
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
|
||||
|
||||
| Redis topology | Session propagation | Rate limiting |
|
||||
| --- | --- | --- |
|
||||
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
|
||||
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
|
||||
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
|
||||
|
||||
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
|
||||
|
||||
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
|
||||
|
||||
### 🔄 Channel Retry and Cache
|
||||
|
||||
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
|
||||
|
||||
**Cache configuration:**
|
||||
- `REDIS_CONN_STRING`: Redis cache (recommended)
|
||||
- `MEMORY_CACHE_ENABLED`: Memory cache
|
||||
For bug reports, include the version, deployment method, reproduction steps, and redacted logs. Documentation, translations, provider integrations, and focused regression tests are all welcome contributions.
|
||||
|
||||
---
|
||||
|
||||
@@ -446,25 +322,23 @@ See [User authentication and login sessions](./docs/authentication.md) for the t
|
||||
|
||||
---
|
||||
|
||||
## 💬 Help Support
|
||||
## 📜 License
|
||||
|
||||
### 📖 Documentation Resources
|
||||
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
| Resource | Link |
|
||||
|------|------|
|
||||
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
|
||||
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
|
||||
Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve
|
||||
the author attribution notice `Frontend design and development by New API
|
||||
contributors.` in the appropriate legal notices and in any prominent about,
|
||||
legal, footer, or attribution location presented by the user interface.
|
||||
|
||||
### 🤝 Contribution Guide
|
||||
Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project: <https://github.com/QuantumNous/new-api>.
|
||||
|
||||
Welcome all forms of contribution!
|
||||
This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) (MIT License).
|
||||
|
||||
- 🐛 Report Bugs
|
||||
- 💡 Propose New Features
|
||||
- 📝 Improve Documentation
|
||||
- 🔧 Submit Code
|
||||
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
See [NOTICE](./NOTICE) and [third-party licenses](./THIRD-PARTY-LICENSES.md) for attribution and dependency notices.
|
||||
|
||||
---
|
||||
|
||||
@@ -484,7 +358,7 @@ Welcome all forms of contribution!
|
||||
|
||||
If this project is helpful to you, welcome to give us a ⭐️ Star!
|
||||
|
||||
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[Official Documentation](https://docs.newapi.ai/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA**
|
||||
**Une passerelle IA pour les modèles, les applications et les agents**
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.zh_CN.md">简体中文</a> |
|
||||
@@ -44,23 +44,34 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-démarrage-rapide">Démarrage rapide</a> •
|
||||
<a href="#-fonctionnalités-clés">Fonctionnalités clés</a> •
|
||||
<a href="#-déploiement">Déploiement</a> •
|
||||
<a href="#-documentation">Documentation</a> •
|
||||
<a href="#-aide-support">Aide</a>
|
||||
<a href="#capabilities">Fonctionnalités</a> •
|
||||
<a href="#quick-start">Démarrage rapide</a> •
|
||||
<a href="#deployment">Déploiement</a> •
|
||||
<a href="#development">Développement</a> •
|
||||
<a href="#documentation">Documentation</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description du projet
|
||||
|
||||
New API est une passerelle IA auto-hébergée pour les applications, les agents et les équipes. Connectez vos fournisseurs de modèles, exposez une API commune à vos clients et gérez le routage, les accès, les usages et les coûts depuis une même console.
|
||||
|
||||
Utilisez-la pour partager des accès autorisés au sein d'une équipe, changer de fournisseur sans reconfigurer chaque client ou exploiter un service privé multi-modèles. Les fournisseurs incluent OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen et d'autres services compatibles.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé.
|
||||
> - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables.
|
||||
> - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables.
|
||||
> - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction.
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Partenaires de confiance
|
||||
@@ -79,9 +90,6 @@
|
||||
--><a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a><!--
|
||||
@@ -106,326 +114,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### Utilisation de Docker Compose (recommandé)
|
||||
## Fonctionnalités
|
||||
|
||||
| Domaine | Possibilités |
|
||||
| --- | --- |
|
||||
| Accès aux modèles | OpenAI Chat Completions, Responses, Anthropic Messages et Gemini ; streaming, outils, raisonnement et entrées multimodales selon le fournisseur |
|
||||
| Routage | Correspondance des noms de modèles, priorités et poids des canaux, tentatives supplémentaires, affinité de canal et gestion de plusieurs clés |
|
||||
| Usages et coûts | Quotas, abonnements, journaux d'utilisation, comptabilisation du cache et tarification par paliers fondée sur des expressions |
|
||||
| Contrôle d'accès | Utilisateurs, groupes, permissions fines et restrictions des clés API ; OAuth/OIDC, clés d'accès, double authentification et gestion des sessions |
|
||||
| Tâches asynchrones | Extensions JavaScript pour les API de tâches d'image, de vidéo et autres, avec suivi d'état et récupération des résultats |
|
||||
| Console web | Gestion des canaux et modèles, journaux d'utilisation et d'audit, playground ; interface en anglais, chinois simplifié et traditionnel, français, japonais, russe et vietnamien |
|
||||
|
||||
### Protocoles et points d'accès
|
||||
|
||||
| Interface | Points d'accès courants |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (mise à niveau WebSocket) |
|
||||
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
|
||||
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
|
||||
| Extensions de tâches | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, ainsi que les routes déclarées par chaque extension |
|
||||
|
||||
[RelayKit](./relaykit/README.md) convertit les requêtes, réponses et flux entre les quatre protocoles textuels. Les capacités disponibles dépendent du canal, du modèle amont et du chemin de conversion ; certains outils et champs propres à un protocole ne sont pas entièrement transposables. WebSocket nécessite également un fournisseur et une configuration de canal compatibles.
|
||||
|
||||
Ce README décrit le code source actuel. Consultez les notes de la version que vous déployez.
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Essai local avec Docker
|
||||
|
||||
Cette commande démarre une instance SQLite accessible uniquement depuis la machine locale :
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
Ouvrez [http://localhost:3000](http://localhost:3000) et suivez l'assistant pour créer le compte administrateur. Le répertoire `data` conserve la base SQLite lors du remplacement du conteneur.
|
||||
|
||||
### Première requête
|
||||
|
||||
1. Ajoutez un canal avec votre clé API amont, ses modèles et son groupe, puis lancez un test du canal.
|
||||
2. Configurez les prix des modèles et vérifiez que l'utilisateur dispose d'un quota ou d'un abonnement valide.
|
||||
3. Créez une clé API dans la console, autorisée à accéder au même groupe et aux mêmes modèles.
|
||||
4. Pour un client compatible OpenAI, utilisez `http://localhost:3000/v1` comme URL de base et la **clé émise par New API**.
|
||||
|
||||
Définissez `NEW_API_KEY` dans votre shell avec cette clé, puis listez les modèles accessibles :
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
Appelez ensuite Responses en remplaçant `your-enabled-model` par un modèle activé qui prend en charge cette interface :
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## Déploiement
|
||||
|
||||
### Docker Compose
|
||||
|
||||
La [configuration Compose](./docker-compose.yml) du dépôt démarre **New API + PostgreSQL + Redis** par défaut. Elle contient aussi des exemples pour MySQL et une base de journaux ClickHouse distincte.
|
||||
|
||||
```bash
|
||||
# Cloner le projet
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Modifier la configuration docker-compose.yml
|
||||
nano docker-compose.yml
|
||||
|
||||
# Démarrer le service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Utilisation des commandes Docker</strong></summary>
|
||||
Avant de démarrer, modifiez `docker-compose.yml` : remplacez les mots de passe d'exemple de la base et de Redis dans les services et les chaînes de connexion, puis définissez un `SESSION_SECRET` aléatoire et persistant (générable avec `openssl rand -hex 32`). Pour une console HTTPS, définissez `SESSION_COOKIE_SECURE=true` et renseignez son origine HTTPS publique exacte dans `SESSION_COOKIE_TRUSTED_URL`.
|
||||
|
||||
```bash
|
||||
# Tirer la dernière image
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# Utilisation de SQLite (par défaut)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# Utilisation de MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data`
|
||||
### Stockage et configuration
|
||||
|
||||
</details>
|
||||
| Composant | Options |
|
||||
| --- | --- |
|
||||
| Base principale | SQLite, MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
|
||||
| Base de journaux distincte | Configurée avec `LOG_SQL_DSN` ; prend aussi en charge ClickHouse |
|
||||
| Cache | Redis facultatif et cache mémoire ; partagez Redis si les nœuds doivent partager les limites de débit |
|
||||
| Plateformes des conteneurs | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| Variable | Rôle |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | Connexion à la base principale ; SQLite si non définie |
|
||||
| `LOG_SQL_DSN` | Connexion facultative à une base de journaux distincte |
|
||||
| `REDIS_CONN_STRING` | Chaîne de connexion Redis |
|
||||
| `SESSION_SECRET` | Secret d'authentification persistant, identique sur tous les nœuds |
|
||||
| `CRYPTO_SECRET` | Vaut `SESSION_SECRET` par défaut ; même valeur effective pour les nœuds partageant Redis |
|
||||
| `SESSION_COOKIE_SECURE` | `true` pour une console HTTPS ; active les cookies de renouvellement Secure et les contrôles stricts d'origine du renouvellement et de la déconnexion |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : origines HTTPS exactes séparées par des virgules, sans chemin ni joker ; à laisser non définie en HTTP local |
|
||||
| `TRUSTED_PROXIES` | IP/CIDR des proxys de confiance, ou `none` ; à configurer selon votre réseau |
|
||||
|
||||
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
|
||||
Consultez l'[exemple d'environnement](./.env.example), la [référence des variables](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables) et le [guide des sessions](./docs/authentication.md). Injectez les variables du conteneur avec `environment` ou `env_file` dans Compose ; copier `.env.example` ne suffit pas.
|
||||
|
||||
> [!WARNING]
|
||||
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
|
||||
En production, utilisez HTTPS et configurez le proxy inverse pour le streaming et WebSocket. Conservez et sauvegardez les bases et les données montées. Tous les nœuds doivent partager la base principale et les secrets d'authentification ; des Redis distincts ou des limiteurs en mémoire comptent les limites séparément par nœud. Le guide des sessions détaille la propagation selon la topologie.
|
||||
|
||||
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
|
||||
Choisissez une version précise d'image dans les [versions publiées](https://github.com/QuantumNous/new-api/releases), lisez ses notes et sauvegardez avant toute mise à niveau. Le tag `latest` évolue avec les publications ; évaluez les migrations et la compatibilité de votre installation.
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 Documentation
|
||||
## Développement et extensions
|
||||
|
||||
<div align="center">
|
||||
Le backend utilise Go et Gin ; la console utilise React 19, TypeScript, Rsbuild, TanStack et Tailwind CSS 4. Utilisez Bun pour le frontend. Consultez [go.mod](./go.mod) pour la version de référence du langage Go et [Dockerfile](./Dockerfile) pour la chaîne de compilation du conteneur.
|
||||
|
||||
### 📖 [Documentation officielle](https://docs.newapi.pro/en/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**Navigation rapide:**
|
||||
|
||||
| Catégorie | Lien |
|
||||
|------|------|
|
||||
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/en/docs/installation) |
|
||||
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/en/docs/api) |
|
||||
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités clés
|
||||
|
||||
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) |
|
||||
|
||||
### 🎨 Fonctions principales
|
||||
|
||||
| Fonctionnalité | Description |
|
||||
|------|------|
|
||||
| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
|
||||
| 🌍 Multilingue | Prend en charge le chinois simplifié, le chinois traditionnel, l'anglais, le français et le japonais |
|
||||
| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
|
||||
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
|
||||
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
|
||||
|
||||
### 💰 Comptabilisation et facturation des usages autorisés
|
||||
|
||||
- ✅ Rechargement interne et allocation de quotas pour les scénarios légalement autorisés (EPay, Stripe)
|
||||
- ✅ Comptabilisation des coûts par requête, par utilisation et par hit de cache au niveau organisationnel
|
||||
- ✅ Statistiques de facturation du cache pour OpenAI, Azure, DeepSeek, Claude, Qwen et les modèles pris en charge
|
||||
- ✅ Politiques de facturation flexibles pour la gestion interne ou les clients entreprise autorisés
|
||||
|
||||
### 🔐 Autorisation et sécurité
|
||||
|
||||
- 😈 Connexion par autorisation Discord
|
||||
- 🤖 Connexion par autorisation LinuxDO
|
||||
- 📱 Connexion par autorisation Telegram
|
||||
- 🔑 Authentification unifiée OIDC
|
||||
- 🔍 Requête de quota d'utilisation de clé (avec [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Fonctionnalités avancées
|
||||
|
||||
**Prise en charge des formats d'API:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||
- 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
|
||||
|
||||
**Routage intelligent:**
|
||||
- ⚖️ Sélection aléatoire pondérée des canaux
|
||||
- 🔄 Nouvelle tentative automatique en cas d'échec
|
||||
- 🚦 Limitation du débit du modèle pour les utilisateurs
|
||||
|
||||
**Conversion de format:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - Texte uniquement, les appels de fonction ne sont pas encore pris en charge
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - En développement
|
||||
- 🔄 **Fonctionnalité de la pensée au contenu**
|
||||
|
||||
**Prise en charge de l'effort de raisonnement:**
|
||||
|
||||
<details>
|
||||
<summary>Voir la configuration détaillée</summary>
|
||||
|
||||
**Modèles de la série OpenAI :**
|
||||
- `o3-mini-high` - Effort de raisonnement élevé
|
||||
- `o3-mini-medium` - Effort de raisonnement moyen
|
||||
- `o3-mini-low` - Effort de raisonnement faible
|
||||
- `gpt-5-high` - Effort de raisonnement élevé
|
||||
- `gpt-5-medium` - Effort de raisonnement moyen
|
||||
- `gpt-5-low` - Effort de raisonnement faible
|
||||
|
||||
**Modèles de pensée de Claude:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
|
||||
|
||||
**Modèles de la série Google Gemini:**
|
||||
- `gemini-2.5-flash-thinking` - Activer le mode de pensée
|
||||
- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
|
||||
- `gemini-2.5-pro-thinking` - Activer le mode de pensée
|
||||
- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
|
||||
- Vous pouvez également ajouter les suffixes `-low`, `-medium` ou `-high` aux modèles Gemini pour fixer le niveau d’effort de raisonnement (sans suffixe de budget supplémentaire).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Prise en charge des modèles
|
||||
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de passerelle](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Type de modèle | Description | Documentation |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI-Compatible | Modèles compatibles OpenAI | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion) |
|
||||
| 🤖 OpenAI Responses | Format OpenAI Responses | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse) |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank) |
|
||||
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | Mode ChatFlow | - |
|
||||
| 🎯 Amont personnalisé | Configuration des points d'accès amont légalement autorisés | - |
|
||||
|
||||
### 📡 Interfaces prises en charge
|
||||
|
||||
<details>
|
||||
<summary>Voir la liste complète des interfaces</summary>
|
||||
|
||||
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion)
|
||||
- [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
|
||||
- [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
|
||||
- [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
|
||||
- [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
|
||||
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
|
||||
- [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage)
|
||||
- [Discussion Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Déploiement
|
||||
|
||||
> [!TIP]
|
||||
> **Dernière image Docker:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 Exigences de déploiement
|
||||
|
||||
| Composant | Exigence |
|
||||
|------|------|
|
||||
| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
|
||||
| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
|
||||
| **Moteur de conteneur** | Docker / Docker Compose |
|
||||
| **Architecture système** | 64 bits uniquement (amd64 / arm64) ; les systèmes 32 bits ne sont pas pris en charge |
|
||||
|
||||
### ⚙️ Configuration des variables d'environnement
|
||||
|
||||
<details>
|
||||
<summary>Configuration courante des variables d'environnement</summary>
|
||||
|
||||
| Nom de variable | Description | Valeur par défaut |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | Secret de signature d’authentification, identique sur tous les nœuds | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/non défini désactive l’OriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de l’Origin | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce n’est pas une liste CORS relay | - |
|
||||
| `TRUSTED_PROXIES` | Variable absente/vide : approuve le bouclage, les réseaux RFC 1918 et l’ULA IPv6 avec un avertissement au démarrage ; `none` n’approuve aucun proxy ; une liste IP/CIDR explicite remplace les valeurs par défaut | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | Nombre maximal de Sessions de connexion actives par utilisateur | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | Nombre maximal de Sessions créées par utilisateur dans la fenêtre, y compris les Sessions révoquées | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Fenêtre de comptage des Sessions ; limitée à la durée de conservation des Sessions révoquées si elle est supérieure | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Conservation en jours des Sessions révoquées pour l’audit et le comptage | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Seuil global horaire déclenchant uniquement une alerte, sans bloquer les connexions | `5000` |
|
||||
| `CRYPTO_SECRET` | Secret HMAC des clés de cache ; les nœuds partageant Redis doivent utiliser la même valeur effective | Par défaut, `SESSION_SECRET` |
|
||||
| `SQL_DSN` | Chaine de connexion à la base de données | - |
|
||||
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
|
||||
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
|
||||
| `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - |
|
||||
| `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` |
|
||||
| `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` |
|
||||
|
||||
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 Méthodes de déploiement
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
|
||||
Compilez le frontend avant de démarrer le backend, qui intègre `web/dist` :
|
||||
|
||||
```bash
|
||||
# Cloner le projet
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Modifier la configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Démarrer le service
|
||||
docker-compose up -d
|
||||
# Racine du dépôt
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
Dans un second terminal, démarrez le serveur de développement frontend :
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 2: Commandes Docker</strong></summary>
|
||||
|
||||
**Utilisation de SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**Utilisation de MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
Ouvrez [http://localhost:5173](http://localhost:5173) ; les requêtes API sont relayées vers le backend sur le port 3000. Pour un backend de développement en conteneur, consultez [docker-compose.dev.yml](./docker-compose.dev.yml) et la cible `make dev` du [makefile](./makefile).
|
||||
|
||||
> **💡 Explication du chemin:**
|
||||
> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel
|
||||
> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data`
|
||||
| Emplacement | Responsabilité |
|
||||
| --- | --- |
|
||||
| `router/`, `middleware/`, `controller/` | Routes HTTP, contrôles d'accès et gestionnaires API |
|
||||
| `relay/` | Adaptateurs amont et routage des requêtes |
|
||||
| `service/`, `model/` | Logique métier et persistance |
|
||||
| [relaykit/](./relaykit/README.md) | Module Go autonome pour les DTO et conversions de protocoles |
|
||||
| [plugins/tasks/](./plugins/tasks/) | Extensions de tâches JavaScript ; contrat et limites de l'hôte dans [Task Plugin API v1](./docs/plugin-api/v1.md) |
|
||||
| `web/` | Console web ; voir les [conventions frontend](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Application de bureau et packaging |
|
||||
|
||||
</details>
|
||||
Lisez [AGENTS.md](./AGENTS.md) avant de contribuer. Exécutez les vérifications adaptées : `make test` pour les modules Go ; `bun run typecheck`, `bun run lint`, `bun run test` et `bun run build` dans `web/` pour le frontend. Une modification de RelayKit nécessite aussi `GOWORK=off go build ./...` depuis `relaykit/`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. Installez le panneau BaoTa (version ≥ 9.2.0)
|
||||
2. Recherchez **New-API** dans le magasin d'applications
|
||||
3. Installation en un clic
|
||||
## Documentation et communauté
|
||||
|
||||
📖 [Tutoriel avec des images](./docs/BT.md)
|
||||
| Ressource | Lien |
|
||||
| --- | --- |
|
||||
| Documentation officielle | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [Référence API](https://docs.newapi.ai/en/docs/api) |
|
||||
| Exploration du projet | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| Questions et échanges | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Communauté](https://docs.newapi.ai/en/docs/support/community-interaction) |
|
||||
| Bugs et propositions | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| Vulnérabilités | Signalement privé selon la [politique de sécurité](./.github/SECURITY.md) |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ Considérations sur le déploiement multi-machines
|
||||
|
||||
> [!WARNING]
|
||||
> - Tous les nœuds doivent utiliser la même base de données principale et la même valeur `SESSION_SECRET` ; sinon les Access Tokens, sessions Refresh et flux d’authentification temporaires ne peuvent pas être vérifiés de façon cohérente.
|
||||
> - Les nœuds connectés au même Redis doivent aussi utiliser le même `CRYPTO_SECRET`, faute de quoi les empreintes de clé de cache diffèrent et les entrées partagées ne peuvent pas être réutilisées de façon cohérente.
|
||||
|
||||
La base de données fait autorité pour les Sessions de connexion et pour les limites actives/d’émission par utilisateur. Les entrées Session de Redis sont des caches de courte durée dont le TTL suit `SYNC_FREQUENCY` (60 secondes par défaut), sans jamais dépasser la durée de vie restante de la Session.
|
||||
|
||||
| Topologie Redis | Propagation des Sessions | Limitation de débit |
|
||||
| --- | --- | --- |
|
||||
| Redis partagé | Les révocations et publications de version se propagent normalement immédiatement | Les quotas Redis sont partagés entre les nœuds |
|
||||
| Redis indépendant par nœud | Les nœuds se resynchronisent depuis la base dans le délai effectif de `SYNC_FREQUENCY` ; un nouveau Token issu d’une rotation peut recevoir temporairement une réponse 401 sur un nœud dont le cache est obsolète | Chaque nœud possède son propre quota ; la capacité agrégée peut donc atteindre environ la limite configurée multipliée par le nombre de nœuds |
|
||||
| Sans Redis | Chaque validation de Session consulte directement la base de données | Les limites en mémoire sont indépendantes sur chaque nœud |
|
||||
|
||||
Réduire `SYNC_FREQUENCY` raccourcit la fenêtre d’obsolescence avec des Redis indépendants, mais ajoute une lecture de Session par clé primaire, par SID actif, par nœud et par TTL. Ces garanties donnent une obsolescence bornée à l’authentification Session ; les limites et les autres caches du plan de contrôle adossés à Redis restent dépendants de la topologie.
|
||||
|
||||
Consultez [Authentification utilisateur et sessions de connexion](./docs/authentication.md) pour les contrats de token, de vérification Origin et de PAT.
|
||||
|
||||
### 🔄 Nouvelle tentative de canal et cache
|
||||
|
||||
**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
|
||||
|
||||
**Configuration du cache:**
|
||||
- `REDIS_CONN_STRING`: Cache Redis (recommandé)
|
||||
- `MEMORY_CACHE_ENABLED`: Cache mémoire
|
||||
Pour signaler un bug, indiquez la version, le déploiement, les étapes de reproduction et des journaux expurgés des données sensibles. Les contributions à la documentation, aux traductions, aux intégrations et aux tests de régression ciblés sont les bienvenues.
|
||||
|
||||
---
|
||||
|
||||
@@ -447,36 +303,18 @@ Consultez [Authentification utilisateur et sessions de connexion](./docs/authent
|
||||
|
||||
---
|
||||
|
||||
## 💬 Aide et support
|
||||
|
||||
### 📖 Ressources de documentation
|
||||
|
||||
| Ressource | Lien |
|
||||
|------|------|
|
||||
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/en/docs/support/feedback-issues) |
|
||||
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/en/docs) |
|
||||
|
||||
### 🤝 Guide de contribution
|
||||
|
||||
Bienvenue à toutes les formes de contribution!
|
||||
|
||||
- 🐛 Signaler des bogues
|
||||
- 💡 Proposer de nouvelles fonctionnalités
|
||||
- 📝 Améliorer la documentation
|
||||
- 🔧 Soumettre du code
|
||||
|
||||
---
|
||||
|
||||
## 📜 Licence
|
||||
|
||||
Ce projet est sous licence [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
Des [conditions supplémentaires](./NOTICE) s'appliquent au titre de la section 7 de l'AGPLv3. Les versions modifiées doivent conserver la mention `Frontend design and development by New API contributors.` dans les mentions légales appropriées et les emplacements visibles de l'interface dédiés aux informations, au droit, au pied de page ou aux attributions, ainsi qu'un lien visible vers le projet original : <https://github.com/QuantumNous/new-api>.
|
||||
|
||||
Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api) (licence MIT).
|
||||
|
||||
Si les politiques de votre organisation ne permettent pas l'utilisation de logiciels sous licence AGPLv3, ou si vous souhaitez éviter les obligations open-source de l'AGPLv3, veuillez nous contacter à : [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
Consultez [NOTICE](./NOTICE) et les [licences tierces](./THIRD-PARTY-LICENSES.md) pour les attributions et les dépendances.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Historique des étoiles
|
||||
@@ -495,7 +333,7 @@ Si les politiques de votre organisation ne permettent pas l'utilisation de logic
|
||||
|
||||
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile!
|
||||
|
||||
**[Documentation officielle](https://docs.newapi.pro/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[Documentation officielle](https://docs.newapi.ai/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Construit avec ❤️ par QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **次世代大規模モデルゲートウェイとAI資産管理システム**
|
||||
**モデル、アプリケーション、Agent をつなぐ AI ゲートウェイ**
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.zh_CN.md">简体中文</a> |
|
||||
@@ -44,23 +44,34 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-クイックスタート">クイックスタート</a> •
|
||||
<a href="#-主な機能">主な機能</a> •
|
||||
<a href="#-デプロイ">デプロイ</a> •
|
||||
<a href="#-ドキュメント">ドキュメント</a> •
|
||||
<a href="#-ヘルプサポート">ヘルプ</a>
|
||||
<a href="#capabilities">主な機能</a> •
|
||||
<a href="#quick-start">クイックスタート</a> •
|
||||
<a href="#deployment">デプロイ</a> •
|
||||
<a href="#development">開発</a> •
|
||||
<a href="#documentation">ドキュメント</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 プロジェクト説明
|
||||
|
||||
New API は、アプリケーション、Agent、チーム向けのセルフホスト型 AI ゲートウェイです。各社のモデルサービスを共通の入口に接続し、ルーティング、アクセス権、使用量、コストを一つの管理画面で扱えます。
|
||||
|
||||
チーム内で許可済みのモデルを共有する、クライアントの再設定を減らしてプロバイダーを切り替える、複数モデルを扱うプライベートサービスを構築するといった用途に使えます。OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、Qwen などに対応しています。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています。
|
||||
> - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります。
|
||||
> - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。
|
||||
> - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
|
||||
|
||||
---
|
||||
|
||||
## 🤝 信頼できるパートナー
|
||||
@@ -79,9 +90,6 @@
|
||||
--><a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a><!--
|
||||
@@ -106,326 +114,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 クイックスタート
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### Docker Composeを使用(推奨)
|
||||
## 主な機能
|
||||
|
||||
| 分野 | できること |
|
||||
| --- | --- |
|
||||
| モデル接続 | OpenAI Chat Completions、Responses、Anthropic Messages、Gemini に対応。上流が対応するストリーミング、ツール呼び出し、推論、マルチモーダル入力を利用可能 |
|
||||
| ルーティング | モデル名のマッピング、チャネルの優先度と重み、再試行、チャネルアフィニティ、複数キーの管理 |
|
||||
| 使用量とコスト | クォータ、サブスクリプション、使用ログ、キャッシュの課金、式による段階的な料金設定 |
|
||||
| アクセス制御 | ユーザー、グループ、詳細な権限、API キーの制限。OAuth/OIDC、パスキー、二要素認証、ログインセッション管理 |
|
||||
| 非同期タスク | JavaScript プラグインで画像・動画などのタスク API を拡張し、状態の確認と成果物の取得に対応 |
|
||||
| Web 管理画面 | チャネルとモデルの設定、使用・監査ログの確認、Playground でのモデル検証。英語、簡体字中国語、繁体字中国語、フランス語、日本語、ロシア語、ベトナム語に対応 |
|
||||
|
||||
### プロトコルとエンドポイント
|
||||
|
||||
| インターフェース | 主なエンドポイント |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket アップグレード) |
|
||||
| 画像 / 音声 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
|
||||
| 埋め込み / リランク | `POST /v1/embeddings`、`POST /v1/rerank` |
|
||||
| タスクプラグイン | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`、各プラグインが宣言するルート |
|
||||
|
||||
[RelayKit](./relaykit/README.md) は、上記 4 種類のテキストプロトコル間でリクエスト、レスポンス、ストリームを変換します。利用できる機能はチャネル、上流モデル、変換経路に依存し、固有のツールやフィールドを完全には変換できない場合があります。WebSocket も対応する上流とチャネル設定が必要です。
|
||||
|
||||
この README は現在のソースコードを説明しています。デプロイするバージョンのリリースノートも確認してください。
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### Docker でローカル実行
|
||||
|
||||
SQLite を使う単一インスタンスを、ローカルホスト限定で起動します。
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
[http://localhost:3000](http://localhost:3000) を開き、初期設定ウィザードで管理者アカウントを作成します。SQLite データベースはマウントした `data` ディレクトリに保存され、コンテナを置き換えても残ります。
|
||||
|
||||
### 最初のリクエスト
|
||||
|
||||
1. 上流の API キー、利用するモデル、グループを設定してチャネルを追加し、チャネルテストを実行します。
|
||||
2. モデルの料金を設定し、ユーザーに利用可能なクォータまたは有効なサブスクリプションがあることを確認します。
|
||||
3. 管理画面で、同じグループとモデルにアクセスできる API キーを作成します。
|
||||
4. OpenAI 互換クライアントでは Base URL を `http://localhost:3000/v1` に設定し、**New API が発行したキー**を使います。
|
||||
|
||||
シェルの `NEW_API_KEY` にそのキーを設定し、アクセス可能なモデルを確認します。
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
次に Responses を呼び出します。`your-enabled-model` は、このインターフェースに対応する有効なモデル名に置き換えてください。
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## デプロイと運用
|
||||
|
||||
### Docker Compose
|
||||
|
||||
リポジトリの [Compose 設定](./docker-compose.yml) は、既定で **New API + PostgreSQL + Redis** を起動します。MySQL と独立した ClickHouse ログデータベースの設定例も含まれます。
|
||||
|
||||
```bash
|
||||
# プロジェクトをクローン
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# docker-compose.yml 設定を編集
|
||||
nano docker-compose.yml
|
||||
|
||||
# サービスを起動
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Dockerコマンドを使用</strong></summary>
|
||||
起動前に `docker-compose.yml` を編集し、データベースと Redis のサービスおよび接続文字列にあるサンプルパスワードを変更します。永続的に使うランダムな `SESSION_SECRET` も設定してください(`openssl rand -hex 32` で生成可能)。HTTPS の管理画面には `SESSION_COOKIE_SECURE=true` を設定し、公開 HTTPS オリジンを `SESSION_COOKIE_TRUSTED_URL` に正確に指定します。
|
||||
|
||||
```bash
|
||||
# 最新のイメージをプル
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# SQLiteを使用(デフォルト)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# MySQLを使用
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data`
|
||||
### ストレージと設定
|
||||
|
||||
</details>
|
||||
| コンポーネント | 選択肢 |
|
||||
| --- | --- |
|
||||
| メインデータベース | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
|
||||
| 独立したログデータベース | `LOG_SQL_DSN` で設定。ClickHouse にも対応 |
|
||||
| キャッシュ | 任意の Redis とメモリキャッシュ。ノード間でレート制限を共有する場合は Redis を共有 |
|
||||
| コンテナのプラットフォーム | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| 環境変数 | 用途 |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | メインデータベースへの接続。未設定なら SQLite |
|
||||
| `LOG_SQL_DSN` | 任意の独立ログデータベースへの接続 |
|
||||
| `REDIS_CONN_STRING` | Redis 接続文字列 |
|
||||
| `SESSION_SECRET` | 永続的な認証用シークレット。全ノードで同じ値を使用 |
|
||||
| `CRYPTO_SECRET` | 既定は `SESSION_SECRET`。Redis を共有するノードは同じ実効値を使用 |
|
||||
| `SESSION_COOKIE_SECURE` | HTTPS の管理画面では `true`。Secure な更新用 Cookie と、更新・ログアウト時の厳格なオリジン検証を有効化 |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。パスやワイルドカードを含まない正確な HTTPS オリジンをカンマ区切りで指定。ローカル HTTP では未設定にする |
|
||||
| `TRUSTED_PROXIES` | 信頼するリバースプロキシの IP/CIDR、または `none`。実際のネットワークに合わせて明示的に設定 |
|
||||
|
||||
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
|
||||
詳細は[環境変数の例](./.env.example)、[環境変数リファレンス](https://docs.newapi.ai/ja/docs/installation/config-maintenance/environment-variables)、[認証とセッションの説明](./docs/authentication.md)を参照してください。コンテナの変数は Compose の `environment` または `env_file` で渡します。`.env.example` のコピーだけではコンテナに反映されません。
|
||||
|
||||
> [!WARNING]
|
||||
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
|
||||
本番環境では HTTPS を使い、リバースプロキシでストリーミングと WebSocket アップグレードを有効にします。データベースとマウントデータを永続化し、バックアップしてください。複数ノードはメインデータベースと認証シークレットを共有します。独立した Redis やメモリ内のレート制限はノードごとに計数します。構成別のセッション伝播については認証の説明を参照してください。
|
||||
|
||||
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
|
||||
[リリース一覧](https://github.com/QuantumNous/new-api/releases)からイメージのバージョンを固定し、更新内容を読んでバックアップしてからアップグレードしてください。`latest` は公開ビルドに応じて変わります。既存環境の移行と互換性は、利用中のバージョンに応じて確認してください。
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 ドキュメント
|
||||
## 開発と拡張
|
||||
|
||||
<div align="center">
|
||||
バックエンドは Go と Gin、管理画面は React 19、TypeScript、Rsbuild、TanStack、Tailwind CSS 4 を使用します。フロントエンドの依存関係とスクリプトには Bun を使います。Go 言語の基準バージョンは [go.mod](./go.mod)、コンテナのビルドツールチェーンは [Dockerfile](./Dockerfile) を参照してください。
|
||||
|
||||
### 📖 [公式ドキュメント](https://docs.newapi.pro/ja/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**クイックナビゲーション:**
|
||||
|
||||
| カテゴリ | リンク |
|
||||
|------|------|
|
||||
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/ja/docs/installation) |
|
||||
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/ja/docs/api) |
|
||||
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
|
||||
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
> 詳細な機能については[機能説明](https://docs.newapi.pro/ja/docs/guide/wiki/basic-concepts/features-introduction)を参照してください。
|
||||
|
||||
### 🎨 コア機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
|
||||
| 🌍 多言語 | 簡体字中国語、繁体字中国語、英語、フランス語、日本語をサポート |
|
||||
| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
|
||||
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
|
||||
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
|
||||
|
||||
### 💰 認可済み利用量とコスト管理
|
||||
|
||||
- ✅ 合法的に許可されたシナリオでの内部チャージとクォータ割り当て(EPay、Stripe)
|
||||
- ✅ 組織レベルのリクエスト単位、使用量ベース、キャッシュヒットのコスト会計
|
||||
- ✅ OpenAI、Azure、DeepSeek、Claude、Qwen などのモデルのキャッシュ課金統計
|
||||
- ✅ 内部管理または認可済み企業顧客向けの柔軟な課金ポリシー
|
||||
|
||||
### 🔐 認証とセキュリティ
|
||||
|
||||
- 😈 Discord認証ログイン
|
||||
- 🤖 LinuxDO認証ログイン
|
||||
- 📱 Telegram認証ログイン
|
||||
- 🔑 OIDC統一認証
|
||||
- 🔍 Key使用量クォータ照会([new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)と併用)
|
||||
|
||||
|
||||
|
||||
### 🚀 高度な機能
|
||||
|
||||
**APIフォーマットサポート:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)(Azureを含む)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
|
||||
- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
|
||||
|
||||
**インテリジェントルーティング:**
|
||||
- ⚖️ チャネル重み付けランダム
|
||||
- 🔄 失敗自動リトライ
|
||||
- 🚦 ユーザーレベルモデルレート制限
|
||||
|
||||
**フォーマット変換:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - テキストのみ、関数呼び出しはまだサポートされていません
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 開発中
|
||||
- 🔄 **思考からコンテンツへの機能**
|
||||
|
||||
**Reasoning Effort サポート:**
|
||||
|
||||
<details>
|
||||
<summary>詳細設定を表示</summary>
|
||||
|
||||
**OpenAIシリーズモデル:**
|
||||
- `o3-mini-high` - 高思考努力
|
||||
- `o3-mini-medium` - 中思考努力
|
||||
- `o3-mini-low` - 低思考努力
|
||||
- `gpt-5-high` - 高思考努力
|
||||
- `gpt-5-medium` - 中思考努力
|
||||
- `gpt-5-low` - 低思考努力
|
||||
|
||||
**Claude思考モデル:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
|
||||
|
||||
**Google Geminiシリーズモデル:**
|
||||
- `gemini-2.5-flash-thinking` - 思考モードを有効にする
|
||||
- `gemini-2.5-flash-nothinking` - 思考モードを無効にする
|
||||
- `gemini-2.5-pro-thinking` - 思考モードを有効にする
|
||||
- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
|
||||
- Gemini モデル名の末尾に `-low` / `-medium` / `-high` を付けることで推論強度を直接指定できます(追加の思考予算サフィックスは不要です)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 モデルサポート
|
||||
|
||||
> 詳細については[APIドキュメント - ゲートウェイインターフェース](https://docs.newapi.pro/ja/docs/api)
|
||||
|
||||
| モデルタイプ | 説明 | ドキュメント |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI-Compatible | OpenAI互換モデル | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createchatcompletion) |
|
||||
| 🤖 OpenAI Responses | OpenAI Responsesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createresponse) |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://doc.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://doc.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/creatererank) |
|
||||
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlowモード | - |
|
||||
| 🎯 カスタム上流 | 合法的に許可された上流エンドポイントの設定をサポート | - |
|
||||
|
||||
### 📡 サポートされているインターフェース
|
||||
|
||||
<details>
|
||||
<summary>完全なインターフェースリストを表示</summary>
|
||||
|
||||
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createchatcompletion)
|
||||
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createresponse)
|
||||
- [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/post-v1-images-generations)
|
||||
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/createembedding)
|
||||
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/creatererank)
|
||||
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/createrealtimesession)
|
||||
- [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage)
|
||||
- [Google Geminiチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 デプロイ
|
||||
|
||||
> [!TIP]
|
||||
> **最新のDockerイメージ:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 デプロイ要件
|
||||
|
||||
| コンポーネント | 要件 |
|
||||
|------|------|
|
||||
| **ローカルデータベース** | SQLite(Dockerは `/data` ディレクトリをマウントする必要があります)|
|
||||
| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
|
||||
| **コンテナエンジン** | Docker / Docker Compose |
|
||||
| **システムアーキテクチャ** | 64ビットのみ対応(amd64 / arm64)。32ビットシステムは非対応 |
|
||||
|
||||
### ⚙️ 環境変数設定
|
||||
|
||||
<details>
|
||||
<summary>一般的な環境変数設定</summary>
|
||||
|
||||
| 変数名 | 説明 | デフォルト値 |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - |
|
||||
| `TRUSTED_PROXIES` | 未設定/空ではループバック、RFC 1918、IPv6 ULA を信頼して起動時に警告し、`none` ではすべて無効、明示的なプロキシ IP/CIDR リストは既定値を完全に置き換えます | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | 1 ユーザーあたりの有効なログイン Session 上限 | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | カウント期間内に作成できる Session 数の上限(取り消し済みを含む) | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 発行のカウント期間(秒)。取り消し済み Session の保持期間を超える場合は自動的に制限 | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | 監査と発行数計算のため取り消し済み Session を保持する日数 | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 1 時間あたりのグローバル Session 発行数の警告閾値。ログインは拒否しません | `5000` |
|
||||
| `CRYPTO_SECRET` | キャッシュキー用 HMAC シークレット。Redis を共有するノードでは同じ実効値が必要 | デフォルトは `SESSION_SECRET` |
|
||||
| `SQL_DSN** | データベース接続文字列 | - |
|
||||
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
|
||||
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
|
||||
| `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - |
|
||||
| `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` |
|
||||
| `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` |
|
||||
|
||||
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 デプロイ方法
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 1: Docker Compose(推奨)</strong></summary>
|
||||
バックエンドは `web/dist` を埋め込むため、先にフロントエンドをビルドします。
|
||||
|
||||
```bash
|
||||
# プロジェクトをクローン
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 設定を編集
|
||||
nano docker-compose.yml
|
||||
|
||||
# サービスを起動
|
||||
docker-compose up -d
|
||||
# リポジトリのルート
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
別のターミナルでフロントエンド開発サーバーを起動します。
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 2: Dockerコマンド</strong></summary>
|
||||
|
||||
**SQLiteを使用:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**MySQLを使用:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
[http://localhost:5173](http://localhost:5173) を開きます。API リクエストはポート 3000 のバックエンドに転送されます。開発用バックエンドをコンテナで動かす場合は [docker-compose.dev.yml](./docker-compose.dev.yml) と [makefile](./makefile) の `make dev` を参照してください。
|
||||
|
||||
> **💡 パス説明:**
|
||||
> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます
|
||||
> - 絶対パスを使用することもできます:`/your/custom/path:/data`
|
||||
| 場所 | 役割 |
|
||||
| --- | --- |
|
||||
| `router/`、`middleware/`、`controller/` | HTTP ルート、アクセス検証、API ハンドラー |
|
||||
| `relay/` | 上流アダプターとリクエストのルーティング |
|
||||
| `service/`、`model/` | ビジネスロジックと永続化 |
|
||||
| [relaykit/](./relaykit/README.md) | プロトコル DTO と変換を提供する、独立してビルド可能な Go モジュール |
|
||||
| [plugins/tasks/](./plugins/tasks/) | JavaScript タスクプラグイン。作成方法とホストの境界は [Task Plugin API v1](./docs/plugin-api/v1.md) を参照 |
|
||||
| `web/` | Web 管理画面。[フロントエンドの規約](./web/AGENTS.md)を参照 |
|
||||
| [electron/](./electron/README.md) | デスクトップアプリのラッパーとパッケージング |
|
||||
|
||||
</details>
|
||||
貢献前に [AGENTS.md](./AGENTS.md) を確認してください。変更に応じて、Go モジュールは `make test`、フロントエンドは `web/` で `bun run typecheck`、`bun run lint`、`bun run test`、`bun run build` を実行します。RelayKit を変更した場合は、`relaykit/` で `GOWORK=off go build ./...` も実行してください。
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 3: 宝塔パネル</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。
|
||||
## ドキュメントとコミュニティ
|
||||
|
||||
📖 [画像付きチュートリアル](./docs/BT.md)
|
||||
| リソース | リンク |
|
||||
| --- | --- |
|
||||
| 公式ドキュメント | [ガイド](https://docs.newapi.ai/ja/docs) · [インストール](https://docs.newapi.ai/ja/docs/installation) · [API リファレンス](https://docs.newapi.ai/ja/docs/api) |
|
||||
| プロジェクトの解説 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| 質問と交流 | [FAQ](https://docs.newapi.ai/ja/docs/support/faq) · [コミュニティ](https://docs.newapi.ai/ja/docs/support/community-interaction) |
|
||||
| 不具合と機能提案 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| 脆弱性 | [セキュリティポリシー](./.github/SECURITY.md)に従って非公開で報告 |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ マルチマシンデプロイの注意事項
|
||||
|
||||
> [!WARNING]
|
||||
> - すべてのノードで同じプライマリデータベースと同じ `SESSION_SECRET` を使用してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません。
|
||||
> - 同じ Redis に接続するノードでは同じ `CRYPTO_SECRET` も設定してください。異なる場合、キャッシュキーのダイジェストが一致せず、共有エントリを正しく再利用できません。
|
||||
|
||||
ログイン Session とユーザー単位の有効数/発行数制限では、データベースが信頼できる唯一の情報源です。Redis の Session エントリは短期キャッシュであり、TTL は `SYNC_FREQUENCY`(デフォルト 60 秒)に従い、Session の残り有効期間を超えません。
|
||||
|
||||
| Redis トポロジー | Session 状態の伝播 | レート制限 |
|
||||
| --- | --- | --- |
|
||||
| すべてのノードで Redis を共有 | 取り消しとバージョン更新は通常即時に伝播 | Redis の制限枠はノード間で共有 |
|
||||
| ノードごとに独立した Redis | 有効な `SYNC_FREQUENCY` 以内にデータベースへフォールバックして収束。バージョンローテーション直後の新しい Token は、古いキャッシュを持つノードで一時的に 401 になる場合があります | ノードごとに独立して計数するため、クラスター全体では設定値の約ノード数倍まで許可される可能性があります |
|
||||
| Redis なし | Session の検証ごとにデータベースを直接参照 | メモリ内の制限枠はノードごとに独立 |
|
||||
|
||||
`SYNC_FREQUENCY` を短くすると独立 Redis のキャッシュ陳腐化時間は短くなりますが、有効な SID ごと、ノードごと、TTL ごとにデータベースへの主キー照会が 1 回増えます。この保証は Session 認証の陳腐化時間を限定するものです。レート制限や Redis を使うその他のコントロールプレーンキャッシュは、引き続きトポロジーに依存します。
|
||||
|
||||
Token、Origin 検証、PAT の契約については[ユーザー認証とログインセッション](./docs/authentication.md)を参照してください。
|
||||
|
||||
### 🔄 チャネルリトライとキャッシュ
|
||||
|
||||
**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
|
||||
|
||||
**キャッシュ設定:**
|
||||
- `REDIS_CONN_STRING`:Redisキャッシュ(推奨)
|
||||
- `MEMORY_CACHE_ENABLED`:メモリキャッシュ
|
||||
不具合の報告には、バージョン、デプロイ方法、再現手順、機密情報を除いたログを添えてください。ドキュメント、翻訳、プロバイダー連携、対象を絞った回帰テストへの貢献も歓迎します。
|
||||
|
||||
---
|
||||
|
||||
@@ -447,36 +303,18 @@ Token、Origin 検証、PAT の契約については[ユーザー認証とログ
|
||||
|
||||
---
|
||||
|
||||
## 💬 ヘルプサポート
|
||||
|
||||
### 📖 ドキュメントリソース
|
||||
|
||||
| リソース | リンク |
|
||||
|------|------|
|
||||
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
|
||||
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
|
||||
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/ja/docs/support/feedback-issues) |
|
||||
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/ja/docs) |
|
||||
|
||||
### 🤝 貢献ガイド
|
||||
|
||||
あらゆる形の貢献を歓迎します!
|
||||
|
||||
- 🐛 バグを報告する
|
||||
- 💡 新しい機能を提案する
|
||||
- 📝 ドキュメントを改善する
|
||||
- 🔧 コードを提出する
|
||||
|
||||
---
|
||||
|
||||
## 📜 ライセンス
|
||||
|
||||
このプロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) の下でライセンスされています。
|
||||
|
||||
AGPLv3 第 7 条に基づく[追加条項](./NOTICE)が適用されます。変更版では、適切な法的表示と、画面上の目立つ概要・法的情報・フッター・著作者表示の箇所に `Frontend design and development by New API contributors.` を残し、元のプロジェクト <https://github.com/QuantumNous/new-api> への可視リンクを保持する必要があります。
|
||||
|
||||
本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)(MITライセンス)をベースに開発されたオープンソースプロジェクトです。
|
||||
|
||||
お客様の組織のポリシーがAGPLv3ライセンスのソフトウェアの使用を許可していない場合、またはAGPLv3のオープンソース義務を回避したい場合は、こちらまでお問い合わせください:[support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
著作者と依存関係の表示については [NOTICE](./NOTICE) と[サードパーティライセンス](./THIRD-PARTY-LICENSES.md)を参照してください。
|
||||
|
||||
---
|
||||
|
||||
## 🌟 スター履歴
|
||||
@@ -495,7 +333,7 @@ Token、Origin 検証、PAT の契約については[ユーザー認証とログ
|
||||
|
||||
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
|
||||
|
||||
**[公式ドキュメント](https://docs.newapi.pro/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[公式ドキュメント](https://docs.newapi.ai/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>❤️ で構築された QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **Next-Generation LLM Gateway and AI Asset Management System**
|
||||
**An AI gateway for models, applications, and agents**
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.zh_CN.md">简体中文</a> |
|
||||
@@ -44,23 +44,49 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-quick-start">Quick Start</a> •
|
||||
<a href="#-key-features">Key Features</a> •
|
||||
<a href="#-deployment">Deployment</a> •
|
||||
<a href="#-documentation">Documentation</a> •
|
||||
<a href="#-help-support">Help</a>
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#capabilities">Capabilities</a> •
|
||||
<a href="#quick-start">Quick start</a> •
|
||||
<a href="#deployment">Deployment</a> •
|
||||
<a href="#development">Development</a> •
|
||||
<a href="#documentation">Documentation</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Project Description
|
||||
|
||||
New API is a self-hosted AI gateway for applications, agents, and teams. Connect upstream model services, expose a consistent API to your clients, and manage routing, access, usage, and costs in one place.
|
||||
|
||||
Use it to share authorized model access across a team, switch providers without configuring every client again, or operate a private multi-model service with a web console. Upstreams include OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen, and other compatible services.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
---
|
||||
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## Screenshots
|
||||
|
||||
Accounts, channels, plugin installation states, usage, prices, and costs shown below are simulated data. The marketplace displays the official plugin catalog. Click an image to view it at full size.
|
||||
|
||||
| Usage dashboard | Plugin marketplace |
|
||||
| --- | --- |
|
||||
| [](assets/screenshots/dashboard.en.jpg) | [](assets/screenshots/plugin-marketplace.en.jpg) |
|
||||
| **Model square** | **Usage logs** |
|
||||
| [](assets/screenshots/models.en.jpg) | [](assets/screenshots/usage-logs.en.jpg) |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Trusted Partners
|
||||
@@ -79,9 +105,6 @@
|
||||
--><a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a><!--
|
||||
@@ -106,328 +129,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
## Capabilities
|
||||
|
||||
| Area | What you can do |
|
||||
| --- | --- |
|
||||
| Model access | Use OpenAI Chat Completions, Responses, Anthropic Messages, and Gemini APIs; stream responses and use tools, reasoning, and multimodal inputs where supported |
|
||||
| Routing | Configure model mappings, channel priorities and weights, retries, channel affinity, and multiple upstream keys |
|
||||
| Usage and costs | Manage quotas, subscriptions, usage logs, cache accounting, and expression-based pricing for different usage tiers |
|
||||
| Access control | Manage users, groups, fine-grained permissions, and API key restrictions; use OAuth/OIDC, passkeys, two-factor authentication, and login session management |
|
||||
| Asynchronous tasks | Extend image, video, and other task APIs with JavaScript plugins, including task status and output retrieval |
|
||||
| Web console | Configure channels and models, inspect usage and audit logs, and try models in the playground; available in English, Simplified Chinese, Traditional Chinese, French, Japanese, Russian, and Vietnamese |
|
||||
|
||||
### Protocols and endpoints
|
||||
|
||||
| Interface | Common endpoints |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (WebSocket upgrade) |
|
||||
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
|
||||
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
|
||||
| Task plugins | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, plus routes declared by each plugin |
|
||||
|
||||
[RelayKit](./relaykit/README.md) provides request, response, and streaming conversion between the four text protocols. Available features depend on the channel, upstream model, and conversion path; protocol-specific tools and fields may not map exactly. WebSocket support also requires a compatible upstream and channel configuration.
|
||||
|
||||
This README describes the current source tree. Check the release notes for the version you deploy.
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## Quick start
|
||||
|
||||
### Try locally with Docker
|
||||
|
||||
This starts a single instance with SQLite and binds it to localhost:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) and complete the setup wizard to create the administrator account. The `data` directory persists the SQLite database across container replacements.
|
||||
|
||||
### Make your first request
|
||||
|
||||
1. Add a channel with your upstream API key, available models, and group assignment; run a channel test.
|
||||
2. Configure model pricing and ensure the user has quota or a valid subscription.
|
||||
3. Create an API key in the console with access to the same group and models.
|
||||
4. Set your client's base URL to `http://localhost:3000/v1` for OpenAI-compatible clients and use the **New API-issued key**.
|
||||
|
||||
Set `NEW_API_KEY` in your shell to that key. List the models accessible to it:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
Then call Responses, replacing `your-enabled-model` with an enabled model that supports this interface:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose
|
||||
|
||||
The repository's [Compose configuration](./docker-compose.yml) starts **New API + PostgreSQL + Redis** by default. It also contains examples for MySQL and a separate ClickHouse log database.
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Edit docker-compose.yml configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start the service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Using Docker Commands</strong></summary>
|
||||
Before starting, edit `docker-compose.yml`: replace the database and Redis example passwords in both the services and connection strings, and set a persistent random `SESSION_SECRET` (generate one with `openssl rand -hex 32`). For an HTTPS console, configure `SESSION_COOKIE_SECURE=true` and `SESSION_COOKIE_TRUSTED_URL` with its exact public HTTPS origin.
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# Using SQLite (default)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# Using MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
|
||||
### Storage and configuration
|
||||
|
||||
</details>
|
||||
| Component | Options |
|
||||
| --- | --- |
|
||||
| Main database | SQLite, MySQL ≥ 5.7.8, or PostgreSQL ≥ 9.6 |
|
||||
| Separate log database | Configure with `LOG_SQL_DSN`; also supports ClickHouse |
|
||||
| Cache | Optional Redis plus in-memory caching; use shared Redis when application nodes need shared rate limits |
|
||||
| Container platforms | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | Main database connection; unset uses SQLite |
|
||||
| `LOG_SQL_DSN` | Optional separate log database connection |
|
||||
| `REDIS_CONN_STRING` | Redis connection string |
|
||||
| `SESSION_SECRET` | Persistent authentication secret; all nodes must use the same value |
|
||||
| `CRYPTO_SECRET` | Defaults to `SESSION_SECRET`; nodes sharing Redis must use the same effective value |
|
||||
| `SESSION_COOKIE_SECURE` | Set to `true` for an HTTPS console; enables Secure refresh cookies and strict refresh/logout origin checks |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Required in Secure mode: comma-separated exact HTTPS origins, without paths or wildcards; leave unset for local HTTP |
|
||||
| `TRUSTED_PROXIES` | Trusted reverse-proxy IPs/CIDRs, or `none`; explicitly configure for your network |
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
See the [environment example](./.env.example), [environment reference](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables), and [authentication and session guide](./docs/authentication.md) for full configuration. Configure container variables in Compose's `environment` or `env_file`; copying `.env.example` alone does not inject variables into the container.
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
For production, put the console behind HTTPS and configure your reverse proxy for streaming and WebSocket upgrades. Persist and back up the database and mounted data. Multi-node deployments must share the main database and authentication secrets; separate Redis instances or in-memory rate limiters count limits independently per node. The session guide describes propagation behavior for each topology.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
Pin an image version from [Releases](https://github.com/QuantumNous/new-api/releases), review its upgrade notes, and back up before upgrading. The `latest` tag follows published builds and can change; migrations and compatibility must be assessed for your existing installation.
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 Documentation
|
||||
## Development and extensions
|
||||
|
||||
<div align="center">
|
||||
The backend uses Go and Gin. The web console uses React 19, TypeScript, Rsbuild, TanStack, and Tailwind CSS 4. Use Bun for frontend dependencies and scripts; see [go.mod](./go.mod) for the Go language baseline and [Dockerfile](./Dockerfile) for the container build toolchain.
|
||||
|
||||
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**Quick Navigation:**
|
||||
|
||||
| Category | Link |
|
||||
|------|------|
|
||||
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
|
||||
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
|
||||
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
|
||||
|
||||
### 🎨 Core Functions
|
||||
|
||||
| Feature | Description |
|
||||
|------|------|
|
||||
| 🎨 New UI | Modern user interface design |
|
||||
| 🌍 Multi-language | Supports Simplified Chinese, Traditional Chinese, English, French, Japanese |
|
||||
| 🔄 Data Compatibility | Fully compatible with the original One API database |
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
- 😈 Discord authorization login
|
||||
- 🤖 LinuxDO authorization login
|
||||
- 📱 Telegram authorization login
|
||||
- 🔑 OIDC unified authentication
|
||||
- 🔍 Key quota query usage (with [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
**API Format Support:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
|
||||
|
||||
**Intelligent Routing:**
|
||||
- ⚖️ Channel weighted random
|
||||
- 🔄 Automatic retry on failure
|
||||
- 🚦 User-level model rate limiting
|
||||
|
||||
**Format Conversion:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
|
||||
- 🔄 **Thinking-to-content functionality**
|
||||
|
||||
**Reasoning Effort Support:**
|
||||
|
||||
<details>
|
||||
<summary>View detailed configuration</summary>
|
||||
|
||||
**OpenAI series models:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude thinking models:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
|
||||
|
||||
**Google Gemini series models:**
|
||||
- `gemini-2.5-flash-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-flash-nothinking` - Disable thinking mode
|
||||
- `gemini-2.5-pro-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
|
||||
- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI-Compatible | OpenAI compatible models | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion) |
|
||||
| 🤖 OpenAI Responses | OpenAI Responses format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse) |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank) |
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
<details>
|
||||
<summary>View complete interface list</summary>
|
||||
|
||||
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion)
|
||||
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
|
||||
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
|
||||
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
|
||||
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
|
||||
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
|
||||
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage)
|
||||
- [Google Gemini Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
> [!TIP]
|
||||
> **Latest Docker image:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 Deployment Requirements
|
||||
|
||||
| Component | Requirement |
|
||||
|------|------|
|
||||
| **Local database** | SQLite (Docker must mount `/data` directory)|
|
||||
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
|
||||
| **Container engine** | Docker / Docker Compose |
|
||||
| **System architecture** | 64-bit only (amd64 / arm64); 32-bit systems are not supported |
|
||||
|
||||
### ⚙️ Environment Variable Configuration
|
||||
|
||||
<details>
|
||||
<summary>Common environment variable configuration</summary>
|
||||
|
||||
| Variable Name | Description | Default Value |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
|
||||
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
|
||||
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
|
||||
| `SQL_DSN` | Database connection string | - |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | - |
|
||||
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
|
||||
| `RELAY_RESPONSE_HEADER_TIMEOUT` | How long the relay waits for upstream **response headers**, seconds; set `0` to disable. Only bounds the header wait -- streaming after the headers arrive is unaffected. Note that non-streaming upstreams usually send headers only once generation finishes, so leave headroom | `1800` |
|
||||
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
|
||||
| `PYROSCOPE_URL` | Pyroscope server address | - |
|
||||
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
|
||||
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
|
||||
|
||||
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 Deployment Methods
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
|
||||
Build the frontend before starting the backend, which embeds `web/dist`:
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Edit configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start service
|
||||
docker-compose up -d
|
||||
# Repository root
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
In a second terminal, start the frontend development server:
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 2: Docker Commands</strong></summary>
|
||||
|
||||
**Using SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**Using MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
Open [http://localhost:5173](http://localhost:5173); the development server proxies API requests to the backend on port 3000. For a containerized development backend, see [docker-compose.dev.yml](./docker-compose.dev.yml) and the `make dev` target in [makefile](./makefile).
|
||||
|
||||
> **💡 Path explanation:**
|
||||
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
|
||||
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
|
||||
| Location | Responsibility |
|
||||
| --- | --- |
|
||||
| `router/`, `middleware/`, `controller/` | HTTP routes, access checks, and API handlers |
|
||||
| `relay/` | Upstream adapters and request routing |
|
||||
| `service/`, `model/` | Business logic and persistence |
|
||||
| [relaykit/](./relaykit/README.md) | Independently buildable Go module for protocol DTOs and conversions |
|
||||
| [plugins/tasks/](./plugins/tasks/) | JavaScript task plugins; see [Task Plugin API v1](./docs/plugin-api/v1.md) for authoring and host boundaries |
|
||||
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
|
||||
|
||||
</details>
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun run typecheck`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 3: BaoTa Panel</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. Install BaoTa Panel (≥ 9.2.0 version)
|
||||
2. Search for **New-API** in the application store
|
||||
3. One-click installation
|
||||
## Documentation and community
|
||||
|
||||
📖 [Tutorial with images](./docs/BT.md)
|
||||
| Resource | Link |
|
||||
| --- | --- |
|
||||
| Official documentation | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [API reference](https://docs.newapi.ai/en/docs/api) |
|
||||
| Project exploration | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| Questions and discussion | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Community](https://docs.newapi.ai/en/docs/support/community-interaction) |
|
||||
| Bugs and feature requests | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| Security reports | Follow the [security policy](./.github/SECURITY.md) for private reporting |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ Multi-machine Deployment Considerations
|
||||
|
||||
> [!WARNING]
|
||||
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
|
||||
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
|
||||
|
||||
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
|
||||
|
||||
| Redis topology | Session propagation | Rate limiting |
|
||||
| --- | --- | --- |
|
||||
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
|
||||
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
|
||||
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
|
||||
|
||||
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
|
||||
|
||||
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
|
||||
|
||||
### 🔄 Channel Retry and Cache
|
||||
|
||||
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
|
||||
|
||||
**Cache configuration:**
|
||||
- `REDIS_CONN_STRING`: Redis cache (recommended)
|
||||
- `MEMORY_CACHE_ENABLED`: Memory cache
|
||||
For bug reports, include the version, deployment method, reproduction steps, and redacted logs. Documentation, translations, provider integrations, and focused regression tests are all welcome contributions.
|
||||
|
||||
---
|
||||
|
||||
@@ -449,28 +318,6 @@ See [User authentication and login sessions](./docs/authentication.md) for the t
|
||||
|
||||
---
|
||||
|
||||
## 💬 Help Support
|
||||
|
||||
### 📖 Documentation Resources
|
||||
|
||||
| Resource | Link |
|
||||
|------|------|
|
||||
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
|
||||
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
|
||||
|
||||
### 🤝 Contribution Guide
|
||||
|
||||
Welcome all forms of contribution!
|
||||
|
||||
- 🐛 Report Bugs
|
||||
- 💡 Propose New Features
|
||||
- 📝 Improve Documentation
|
||||
- 🔧 Submit Code
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
@@ -487,6 +334,8 @@ This is an open-source project developed based on [One API](https://github.com/s
|
||||
|
||||
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
See [NOTICE](./NOTICE) and [third-party licenses](./THIRD-PARTY-LICENSES.md) for attribution and dependency notices.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
@@ -505,7 +354,7 @@ If your organization's policies do not permit the use of AGPLv3-licensed softwar
|
||||
|
||||
If this project is helpful to you, welcome to give us a ⭐️ Star!
|
||||
|
||||
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[Official Documentation](https://docs.newapi.ai/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **新一代大模型网关与AI资产管理系统**
|
||||
**连接模型、应用与 Agent 的 AI 网关**
|
||||
|
||||
<p align="center">
|
||||
简体中文 |
|
||||
<strong>简体中文</strong> |
|
||||
<a href="./README.zh_TW.md">繁體中文</a> |
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
@@ -44,23 +44,49 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-快速开始">快速开始</a> •
|
||||
<a href="#-主要特性">主要特性</a> •
|
||||
<a href="#-部署">部署</a> •
|
||||
<a href="#-文档">文档</a> •
|
||||
<a href="#-帮助支持">帮助</a>
|
||||
<a href="#screenshots">项目截图</a> •
|
||||
<a href="#capabilities">核心能力</a> •
|
||||
<a href="#quick-start">快速开始</a> •
|
||||
<a href="#deployment">部署运维</a> •
|
||||
<a href="#development">开发扩展</a> •
|
||||
<a href="#documentation">文档社区</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 项目说明
|
||||
|
||||
New API 是面向应用、Agent 和团队的自托管 AI 网关。将不同厂商的模型服务接入统一入口,在同一套控制台中管理渠道、访问权限、用量与成本。
|
||||
|
||||
你可以用它为团队分配已授权的模型资源,在切换上游时减少客户端改动,或搭建自己的多模型服务。支持接入 OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、通义千问及其他兼容服务。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。
|
||||
> - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。
|
||||
> - 使用者应确保其使用方式符合上游服务条款及适用法律法规。
|
||||
> - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
|
||||
|
||||
---
|
||||
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## 项目截图
|
||||
|
||||
以下账号、渠道、插件安装状态、用量、价格和费用均为模拟数据,插件市场展示官方目录。点击图片可查看原图。
|
||||
|
||||
| 数据看板 | 插件市场 |
|
||||
| --- | --- |
|
||||
| [](assets/screenshots/dashboard.zh-CN.jpg) | [](assets/screenshots/plugin-marketplace.zh-CN.jpg) |
|
||||
| **模型广场** | **使用日志** |
|
||||
| [](assets/screenshots/models.zh-CN.jpg) | [](assets/screenshots/usage-logs.zh-CN.jpg) |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 我们信任的合作伙伴
|
||||
@@ -79,9 +105,6 @@
|
||||
--><a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="阿里云" height="80" />
|
||||
</a><!--
|
||||
@@ -106,326 +129,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
## 核心能力
|
||||
|
||||
| 方向 | 可以做什么 |
|
||||
| --- | --- |
|
||||
| 模型接入 | 支持 OpenAI Chat Completions、Responses、Anthropic Messages 和 Gemini 协议,以及上游支持的流式输出、工具调用、推理与多模态输入 |
|
||||
| 渠道调度 | 配置模型映射、渠道优先级与权重、失败重试、渠道亲和性和多密钥管理 |
|
||||
| 用量与成本 | 管理额度、订阅套餐、用量日志、缓存计费,以及基于表达式的阶梯定价 |
|
||||
| 访问控制 | 管理用户、分组、细粒度权限和 API Key 限制;支持 OAuth/OIDC、通行密钥、两步验证与登录会话管理 |
|
||||
| 异步任务 | 通过 JavaScript 插件扩展图片、视频等任务 API,统一查询任务状态和获取产物 |
|
||||
| Web 控制台 | 配置渠道与模型、查看用量和审计日志、在 Playground 中调试模型;支持简体中文、繁体中文、英语、法语、日语、俄语和越南语 |
|
||||
|
||||
### 协议与接口
|
||||
|
||||
| 接口类型 | 常用入口 |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket 升级) |
|
||||
| 图片 / 音频 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
|
||||
| 向量 / 重排 | `POST /v1/embeddings`、`POST /v1/rerank` |
|
||||
| 任务插件 | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`,以及各插件声明的协议路由 |
|
||||
|
||||
[RelayKit](./relaykit/README.md) 提供上述四种文本协议之间的请求、响应和流式转换。实际可用能力取决于渠道、上游模型和转换路径;协议特有的工具与字段可能无法完整映射。WebSocket 同样需要上游与渠道配置支持。
|
||||
|
||||
本 README 描述当前源码的能力,部署时请同时查看所选版本的发布说明。
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用 Docker 本地体验
|
||||
|
||||
以下命令使用 SQLite 启动单实例,仅监听本机端口:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000),按初始化向导创建管理员账号。SQLite 数据库存放在挂载的 `data` 目录中,更换容器后仍会保留。
|
||||
|
||||
### 发起第一次请求
|
||||
|
||||
1. 新建渠道,填写上游 API Key、可用模型和所属分组,执行渠道测试。
|
||||
2. 配置模型价格,确保调用用户有可用额度或有效订阅。
|
||||
3. 在控制台创建 API Key,确保它能访问对应分组和模型。
|
||||
4. 对于 OpenAI 兼容客户端,将 Base URL 设为 `http://localhost:3000/v1`,密钥使用 **New API 签发的 Key**。
|
||||
|
||||
在终端中将 `NEW_API_KEY` 环境变量设为该密钥,查询它可以访问的模型:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
然后调用 Responses,将 `your-enabled-model` 替换为已启用且支持该接口的模型名称:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## 部署与运维
|
||||
|
||||
### Docker Compose
|
||||
|
||||
仓库的 [Compose 配置](./docker-compose.yml) 默认启动 **New API + PostgreSQL + Redis**,并提供 MySQL 和独立 ClickHouse 日志库的配置示例。
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 编辑 docker-compose.yml 配置
|
||||
nano docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>使用 Docker 命令</strong></summary>
|
||||
启动前编辑 `docker-compose.yml`:同时替换数据库、Redis 服务及对应连接串中的示例密码,并设置固定的随机 `SESSION_SECRET`(可用 `openssl rand -hex 32` 生成)。通过 HTTPS 访问控制台时,设置 `SESSION_COOKIE_SECURE=true`,并在 `SESSION_COOKIE_TRUSTED_URL` 中填写控制台对外的精确 HTTPS Origin。
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# 使用 SQLite(默认)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# 使用 MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data`
|
||||
### 存储与配置
|
||||
|
||||
</details>
|
||||
| 组件 | 可选方案 |
|
||||
| --- | --- |
|
||||
| 主数据库 | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
|
||||
| 独立日志库 | 通过 `LOG_SQL_DSN` 配置,额外支持 ClickHouse |
|
||||
| 缓存 | 可选 Redis 与内存缓存;多节点需要共享限流额度时使用共享 Redis |
|
||||
| 容器平台 | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| 环境变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | 主数据库连接串;未设置时使用 SQLite |
|
||||
| `LOG_SQL_DSN` | 可选的独立日志数据库连接串 |
|
||||
| `REDIS_CONN_STRING` | Redis 连接串 |
|
||||
| `SESSION_SECRET` | 持久保存的鉴权密钥,所有节点必须一致 |
|
||||
| `CRYPTO_SECRET` | 默认使用 `SESSION_SECRET`;共享 Redis 的节点必须使用相同的有效值 |
|
||||
| `SESSION_COOKIE_SECURE` | HTTPS 控制台设为 `true`,启用 Secure 刷新 Cookie 和严格的刷新/退出来源校验 |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:以逗号分隔的精确 HTTPS Origin,不含路径或通配符;本地 HTTP 模式不设置 |
|
||||
| `TRUSTED_PROXIES` | 可信反向代理 IP/CIDR,或 `none`;按实际网络显式配置 |
|
||||
|
||||
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
|
||||
完整配置见[环境变量示例](./.env.example)、[环境变量文档](https://docs.newapi.ai/zh/docs/installation/config-maintenance/environment-variables)和[鉴权与登录会话说明](./docs/authentication.md)。容器变量应通过 Compose 的 `environment` 或 `env_file` 注入;只复制 `.env.example` 不会自动将变量传入容器。
|
||||
|
||||
> [!WARNING]
|
||||
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
|
||||
正式部署时使用 HTTPS,并配置反向代理支持流式响应与 WebSocket 升级。持久化并备份数据库和挂载数据。多节点必须共用主数据库和鉴权密钥;独立 Redis 或内存限流器会按节点分别计数。不同拓扑下的会话传播行为见鉴权文档。
|
||||
|
||||
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
从[发布页](https://github.com/QuantumNous/new-api/releases)选择明确的镜像版本,阅读升级说明并先备份再升级。`latest` 会随发布构建变化;已有实例应按实际版本评估迁移与兼容性。
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 文档
|
||||
## 开发与扩展
|
||||
|
||||
<div align="center">
|
||||
后端使用 Go 和 Gin;控制台使用 React 19、TypeScript、Rsbuild、TanStack 与 Tailwind CSS 4。前端依赖和脚本使用 Bun;Go 语言基线见 [go.mod](./go.mod),容器构建工具链见 [Dockerfile](./Dockerfile)。
|
||||
|
||||
### 📖 [官方文档](https://docs.newapi.pro/zh/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**快速导航:**
|
||||
|
||||
| 分类 | 链接 |
|
||||
|------|------|
|
||||
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/zh/docs/installation) |
|
||||
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/zh/docs/api) |
|
||||
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
|
||||
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
> 详细特性请参考 [特性说明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
|
||||
|
||||
### 🎨 核心功能
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 🎨 全新 UI | 现代化的用户界面设计 |
|
||||
| 🌍 多语言 | 支持中文、英文、法语、日语 |
|
||||
| 🔄 数据兼容 | 完全兼容原版 One API 数据库 |
|
||||
| 📈 数据看板 | 可视化控制台与统计分析 |
|
||||
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
|
||||
|
||||
### 💰 授权用量与成本管理
|
||||
|
||||
- ✅ 合法授权场景下的内部充值与额度分配(易支付、Stripe)
|
||||
- ✅ 组织内按次、按量或缓存命中成本核算
|
||||
- ✅ 支持 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的缓存计费统计
|
||||
- ✅ 面向内部管理或企业客户的灵活计费策略配置
|
||||
|
||||
### 🔐 授权与安全
|
||||
|
||||
- 😈 Discord 授权登录
|
||||
- 🤖 LinuxDO 授权登录
|
||||
- 📱 Telegram 授权登录
|
||||
- 🔑 OIDC 统一认证
|
||||
- 🔍 Key 查询使用额度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高级功能
|
||||
|
||||
**API 格式支持:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
|
||||
- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
|
||||
|
||||
**智能路由:**
|
||||
- ⚖️ 渠道加权随机
|
||||
- 🔄 失败自动重试
|
||||
- 🚦 用户级别模型限流
|
||||
|
||||
**格式转换:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - 仅支持文本,暂不支持函数调用
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 开发中
|
||||
- 🔄 **思考转内容功能**
|
||||
|
||||
**Reasoning Effort 支持:**
|
||||
|
||||
<details>
|
||||
<summary>查看详细配置</summary>
|
||||
|
||||
**OpenAI 系列模型:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude 思考模型:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式
|
||||
|
||||
**Google Gemini 系列模型:**
|
||||
- `gemini-2.5-flash-thinking` - 启用思考模式
|
||||
- `gemini-2.5-flash-nothinking` - 禁用思考模式
|
||||
- `gemini-2.5-pro-thinking` - 启用思考模式
|
||||
- `gemini-2.5-pro-thinking-128` - 启用思考模式,并设置思考预算为128tokens
|
||||
- 也可以直接在 Gemini 模型名称后追加 `-low` / `-medium` / `-high` 来控制思考力度(无需再设置思考预算后缀)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 模型支持
|
||||
|
||||
> 详情请参考 [接口文档 - 网关接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型类型 | 说明 | 文档 |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI-Compatible | OpenAI 兼容模型 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion) |
|
||||
| 🤖 OpenAI Responses | OpenAI Responses 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse) |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://doc.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://doc.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
|
||||
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自定义上游 | 支持配置合法授权的上游接口地址 | - |
|
||||
|
||||
### 📡 支持的接口
|
||||
|
||||
<details>
|
||||
<summary>查看完整接口列表</summary>
|
||||
|
||||
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion)
|
||||
- [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
|
||||
- [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
|
||||
- [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
|
||||
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
|
||||
- [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
|
||||
- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage)
|
||||
- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 部署
|
||||
|
||||
> [!TIP]
|
||||
> **最新版 Docker 镜像:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 部署要求
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| **本地数据库** | SQLite(Docker 需挂载 `/data` 目录)|
|
||||
| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
|
||||
| **容器引擎** | Docker / Docker Compose |
|
||||
| **系统架构** | 仅支持 64 位系统(amd64 / arm64),不支持 32 位系统 |
|
||||
|
||||
### ⚙️ 环境变量配置
|
||||
|
||||
<details>
|
||||
<summary>常用环境变量配置</summary>
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|--------------------------------------------------------------|--------|
|
||||
| `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - |
|
||||
| `TRUSTED_PROXIES` | 未配置/留空时信任回环、RFC1918 和 IPv6 ULA 并输出启动告警;`none` 不信任任何代理;显式代理 IP/CIDR 列表完全替代默认值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | 单用户最大活跃登录 Session 数 | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | 单用户在签发窗口内可创建的 Session 总数,包含已撤销 Session | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 签发计数窗口(秒);高于 revoked 保留期时自动钳制 | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用于审计和签发计数的保留天数 | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全局每小时 Session 签发告警阈值;只告警,不拒绝登录 | `5000` |
|
||||
| `CRYPTO_SECRET` | 缓存键 HMAC 密钥;共享 Redis 的节点必须使用相同有效值 | 默认跟随 `SESSION_SECRET` |
|
||||
| `SQL_DSN` | 数据库连接字符串 | - |
|
||||
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
|
||||
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
|
||||
| `PYROSCOPE_URL` | Pyroscope 服务地址 | - |
|
||||
| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` |
|
||||
| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` |
|
||||
|
||||
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 部署方式
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 1:Docker Compose(推荐)</strong></summary>
|
||||
后端会嵌入 `web/dist`,首次启动前先构建前端:
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 编辑配置
|
||||
nano docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
# 仓库根目录
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
在另一个终端启动前端开发服务器:
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 2:Docker 命令</strong></summary>
|
||||
|
||||
**使用 SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**使用 MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
访问 [http://localhost:5173](http://localhost:5173),开发服务器会将 API 请求代理到 3000 端口的后端。需要容器化开发后端时,参阅 [docker-compose.dev.yml](./docker-compose.dev.yml) 和 [makefile](./makefile) 中的 `make dev`。
|
||||
|
||||
> **💡 路径说明:**
|
||||
> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹
|
||||
> - 也可使用绝对路径,如:`/your/custom/path:/data`
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `router/`、`middleware/`、`controller/` | HTTP 路由、访问校验与 API 处理 |
|
||||
| `relay/` | 上游适配与请求调度 |
|
||||
| `service/`、`model/` | 业务逻辑与持久化 |
|
||||
| [relaykit/](./relaykit/README.md) | 可独立构建的协议 DTO 与转换 Go 模块 |
|
||||
| [plugins/tasks/](./plugins/tasks/) | JavaScript 任务插件;编写方式与宿主边界见 [Task Plugin API v1](./docs/plugin-api/v1.md) |
|
||||
| `web/` | Web 控制台,参阅[前端开发约定](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | 桌面封装与打包 |
|
||||
|
||||
</details>
|
||||
贡献前请阅读 [AGENTS.md](./AGENTS.md)。按改动范围执行检查:Go 模块使用 `make test`;前端在 `web/` 下运行 `bun run typecheck`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 后还必须在 `relaykit/` 下执行 `GOWORK=off go build ./...`。
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 3:宝塔面板</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. 安装宝塔面板(≥ 9.2.0 版本)
|
||||
2. 在应用商店搜索 **New-API**
|
||||
3. 一键安装
|
||||
## 文档与社区
|
||||
|
||||
📖 [图文教程](./docs/installation/BT.md)
|
||||
| 资源 | 入口 |
|
||||
| --- | --- |
|
||||
| 官方文档 | [使用指南](https://docs.newapi.ai/zh/docs) · [安装部署](https://docs.newapi.ai/zh/docs/installation) · [API 参考](https://docs.newapi.ai/zh/docs/api) |
|
||||
| 项目导读 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| 使用问题与交流 | [常见问题](https://docs.newapi.ai/zh/docs/support/faq) · [社区渠道](https://docs.newapi.ai/zh/docs/support/community-interaction) |
|
||||
| 缺陷与功能建议 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| 安全漏洞 | 按[安全政策](./.github/SECURITY.md)进行私下报告 |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ 多机部署注意事项
|
||||
|
||||
> [!WARNING]
|
||||
> - 所有节点必须使用同一个主数据库,并设置相同的 `SESSION_SECRET`;否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验。
|
||||
> - 连接同一个 Redis 的节点还必须设置相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。
|
||||
|
||||
登录 Session 和单用户活跃数/签发数限制均以数据库为权威。Redis 中的 Session 仅为短期缓存,TTL 跟随 `SYNC_FREQUENCY`(默认 60 秒),且不会超过 Session 的剩余寿命。
|
||||
|
||||
| Redis 拓扑 | Session 状态传播 | 限流语义 |
|
||||
| --- | --- | --- |
|
||||
| 所有节点共享 Redis | 撤销和版本发布通常即时传播 | Redis 限流额度在节点间共享 |
|
||||
| 每个节点使用独立 Redis | 最迟在有效 `SYNC_FREQUENCY` 内回源数据库收敛;版本轮换后,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 |
|
||||
| 不使用 Redis | 每次 Session 校验直接读取数据库 | 各节点使用独立的内存限流额度 |
|
||||
|
||||
缩短 `SYNC_FREQUENCY` 可减小独立 Redis 的陈旧窗口,但每个活跃 SID 在每个节点上会按该 TTL 增加一次数据库主键点查。上述保证只让 Session 鉴权在不同拓扑下保持有界陈旧;限流和其他 Redis 控制面缓存仍受拓扑影响。
|
||||
|
||||
Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authentication.md)。
|
||||
|
||||
### 🔄 渠道重试与缓存
|
||||
|
||||
**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数`
|
||||
|
||||
**缓存配置:**
|
||||
- `REDIS_CONN_STRING`:Redis 缓存(推荐)
|
||||
- `MEMORY_CACHE_ENABLED`:内存缓存
|
||||
反馈问题时请附上版本、部署方式、复现步骤及脱敏日志。欢迎贡献文档、翻译、渠道适配和有针对性的回归测试。
|
||||
|
||||
---
|
||||
|
||||
@@ -447,36 +318,18 @@ Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authe
|
||||
|
||||
---
|
||||
|
||||
## 💬 帮助支持
|
||||
|
||||
### 📖 文档资源
|
||||
|
||||
| 资源 | 链接 |
|
||||
|------|------|
|
||||
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
|
||||
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
|
||||
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
|
||||
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/zh/docs) |
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
欢迎各种形式的贡献!
|
||||
|
||||
- 🐛 报告 Bug
|
||||
- 💡 提出新功能
|
||||
- 📝 改进文档
|
||||
- 🔧 提交代码
|
||||
|
||||
---
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
|
||||
|
||||
根据 AGPLv3 第 7 条,本项目还适用[附加条款](./NOTICE)。修改版本必须在适当法律声明及界面中显著的关于、法律、页脚或署名位置保留作者署名 `Frontend design and development by New API contributors.`,并保留指向原项目 <https://github.com/QuantumNous/new-api> 的可见链接。
|
||||
|
||||
本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 许可证)的基础上进行二次开发。
|
||||
|
||||
如果您所在的组织政策不允许使用 AGPLv3 许可的软件,或您希望规避 AGPLv3 的开源义务,请发送邮件至:[support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
署名及依赖声明见 [NOTICE](./NOTICE) 和[第三方许可证](./THIRD-PARTY-LICENSES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
@@ -495,7 +348,7 @@ Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authe
|
||||
|
||||
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star!
|
||||
|
||||
**[官方文档](https://docs.newapi.pro/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[官方文档](https://docs.newapi.ai/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
# New API
|
||||
|
||||
🍥 **新一代大模型網關與AI資產管理系統**
|
||||
**連接模型、應用程式與 Agent 的 AI 閘道**
|
||||
|
||||
<p align="center">
|
||||
繁體中文 |
|
||||
<a href="./README.zh_CN.md">简体中文</a> |
|
||||
<strong>繁體中文</strong> |
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<a href="./README.ja.md">日本語</a>
|
||||
@@ -43,23 +43,34 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-快速開始">快速開始</a> •
|
||||
<a href="#-主要特性">主要特性</a> •
|
||||
<a href="#-部署">部署</a> •
|
||||
<a href="#-文件">文件</a> •
|
||||
<a href="#-幫助支援">幫助</a>
|
||||
<a href="#capabilities">核心能力</a> •
|
||||
<a href="#quick-start">快速開始</a> •
|
||||
<a href="#deployment">部署維運</a> •
|
||||
<a href="#development">開發擴充</a> •
|
||||
<a href="#documentation">文件社群</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 項目說明
|
||||
|
||||
New API 是面向應用程式、Agent 和團隊的自託管 AI 閘道。將不同供應商的模型服務接入統一入口,在同一套控制台中管理渠道、存取權限、用量與成本。
|
||||
|
||||
你可以用它為團隊分配已授權的模型資源,在切換上游時減少用戶端改動,或建置自己的多模型服務。支援接入 OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、通義千問及其他相容服務。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。
|
||||
> - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。
|
||||
> - 使用者應確保其使用方式符合上游服務條款及適用法律法規。
|
||||
> - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。
|
||||
|
||||
<!-- -->
|
||||
|
||||
> [!WARNING]
|
||||
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
|
||||
|
||||
---
|
||||
|
||||
## 🤝 我們信任的合作伙伴
|
||||
@@ -78,9 +89,6 @@
|
||||
--><a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="北京大學" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
|
||||
</a><!--
|
||||
--><a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
|
||||
</a><!--
|
||||
@@ -105,326 +113,174 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速開始
|
||||
<a id="capabilities"></a>
|
||||
|
||||
### 使用 Docker Compose(推薦)
|
||||
## 核心能力
|
||||
|
||||
| 方向 | 可以做什麼 |
|
||||
| --- | --- |
|
||||
| 模型接入 | 支援 OpenAI Chat Completions、Responses、Anthropic Messages 和 Gemini 協定,以及上游支援的串流輸出、工具呼叫、推理與多模態輸入 |
|
||||
| 渠道調度 | 設定模型映射、渠道優先順序與權重、失敗重試、渠道親和性和多金鑰管理 |
|
||||
| 用量與成本 | 管理額度、訂閱方案、用量日誌、快取計費,以及基於運算式的階梯定價 |
|
||||
| 存取控制 | 管理使用者、群組、細粒度權限和 API Key 限制;支援 OAuth/OIDC、通行密鑰、兩步驟驗證與登入工作階段管理 |
|
||||
| 非同步任務 | 透過 JavaScript 外掛擴充圖片、影片等任務 API,統一查詢任務狀態和取得產物 |
|
||||
| Web 控制台 | 設定渠道與模型、查看用量和稽核日誌、在 Playground 中測試模型;支援簡體中文、繁體中文、英語、法語、日語、俄語和越南語 |
|
||||
|
||||
### 協定與介面
|
||||
|
||||
| 介面類型 | 常用入口 |
|
||||
| --- | --- |
|
||||
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
|
||||
| Anthropic Messages | `POST /v1/messages` |
|
||||
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
|
||||
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket 升級) |
|
||||
| 圖片 / 音訊 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
|
||||
| 向量 / 重排 | `POST /v1/embeddings`、`POST /v1/rerank` |
|
||||
| 任務外掛 | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`,以及各外掛宣告的協定路由 |
|
||||
|
||||
[RelayKit](./relaykit/README.md) 提供上述四種文字協定之間的請求、回應和串流轉換。實際可用能力取決於渠道、上游模型和轉換路徑;協定特有的工具與欄位可能無法完整映射。WebSocket 同樣需要上游與渠道設定支援。
|
||||
|
||||
本 README 描述目前原始碼的能力,部署時請同時查看所選版本的發行說明。
|
||||
|
||||
<a id="quick-start"></a>
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 使用 Docker 本機體驗
|
||||
|
||||
以下指令使用 SQLite 啟動單一執行個體,僅監聽本機連接埠:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker run --name new-api -d --restart unless-stopped \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v "$(pwd)/data:/data" \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
開啟 [http://localhost:3000](http://localhost:3000),依初始化精靈建立管理員帳號。SQLite 資料庫存放在掛載的 `data` 目錄中,更換容器後仍會保留。
|
||||
|
||||
### 發起第一次請求
|
||||
|
||||
1. 新增渠道,填寫上游 API Key、可用模型和所屬群組,執行渠道測試。
|
||||
2. 設定模型價格,確保呼叫使用者有可用額度或有效訂閱。
|
||||
3. 在控制台建立 API Key,確保它能存取對應群組和模型。
|
||||
4. 對於 OpenAI 相容用戶端,將 Base URL 設為 `http://localhost:3000/v1`,金鑰使用 **New API 簽發的 Key**。
|
||||
|
||||
在終端機中將 `NEW_API_KEY` 環境變數設為該金鑰,查詢它可以存取的模型:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/models \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}"
|
||||
```
|
||||
|
||||
然後呼叫 Responses,將 `your-enabled-model` 替換為已啟用且支援該介面的模型名稱:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body http://localhost:3000/v1/responses \
|
||||
-H "Authorization: Bearer ${NEW_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-enabled-model","input":"Hello!"}'
|
||||
```
|
||||
|
||||
<a id="deployment"></a>
|
||||
|
||||
## 部署與維運
|
||||
|
||||
### Docker Compose
|
||||
|
||||
儲存庫的 [Compose 設定](./docker-compose.yml) 預設啟動 **New API + PostgreSQL + Redis**,並提供 MySQL 和獨立 ClickHouse 日誌資料庫的設定範例。
|
||||
|
||||
```bash
|
||||
# 複製項目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 編輯 docker-compose.yml 配置
|
||||
nano docker-compose.yml
|
||||
|
||||
# 啟動服務
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>使用 Docker 命令</strong></summary>
|
||||
啟動前編輯 `docker-compose.yml`:同時替換資料庫、Redis 服務及對應連線字串中的範例密碼,並設定固定的隨機 `SESSION_SECRET`(可用 `openssl rand -hex 32` 產生)。透過 HTTPS 存取控制台時,設定 `SESSION_COOKIE_SECURE=true`,並在 `SESSION_COOKIE_TRUSTED_URL` 中填寫控制台對外的精確 HTTPS Origin。
|
||||
|
||||
```bash
|
||||
# 拉取最新鏡像
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# 使用 SQLite(預設)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# 使用 MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
docker compose up -d
|
||||
docker compose logs -f new-api
|
||||
```
|
||||
|
||||
> **💡 提示:** `-v ./data:/data` 會將數據保存在當前目錄的 `data` 資料夾中,你也可以改為絕對路徑如 `-v /your/custom/path:/data`
|
||||
### 儲存與設定
|
||||
|
||||
</details>
|
||||
| 元件 | 可選方案 |
|
||||
| --- | --- |
|
||||
| 主資料庫 | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
|
||||
| 獨立日誌資料庫 | 透過 `LOG_SQL_DSN` 設定,額外支援 ClickHouse |
|
||||
| 快取 | 可選 Redis 與記憶體快取;多節點需要共享限流額度時使用共享 Redis |
|
||||
| 容器平台 | Linux amd64 / arm64 |
|
||||
|
||||
---
|
||||
| 環境變數 | 用途 |
|
||||
| --- | --- |
|
||||
| `SQL_DSN` | 主資料庫連線字串;未設定時使用 SQLite |
|
||||
| `LOG_SQL_DSN` | 可選的獨立日誌資料庫連線字串 |
|
||||
| `REDIS_CONN_STRING` | Redis 連線字串 |
|
||||
| `SESSION_SECRET` | 持久保存的驗證金鑰,所有節點必須一致 |
|
||||
| `CRYPTO_SECRET` | 預設使用 `SESSION_SECRET`;共享 Redis 的節點必須使用相同的有效值 |
|
||||
| `SESSION_COOKIE_SECURE` | HTTPS 控制台設為 `true`,啟用 Secure 更新 Cookie 和嚴格的更新/登出來源驗證 |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:以逗號分隔的精確 HTTPS Origin,不含路徑或萬用字元;本機 HTTP 模式不設定 |
|
||||
| `TRUSTED_PROXIES` | 可信反向代理 IP/CIDR,或 `none`;依實際網路明確設定 |
|
||||
|
||||
🎉 部署完成後,訪問 `http://localhost:3000` 即可使用!
|
||||
完整設定見[環境變數範例](./.env.example)、[環境變數文件](https://docs.newapi.ai/zh/docs/installation/config-maintenance/environment-variables)和[驗證與登入工作階段說明](./docs/authentication.md)。容器變數應透過 Compose 的 `environment` 或 `env_file` 注入;只複製 `.env.example` 不會自動將變數傳入容器。
|
||||
|
||||
> [!WARNING]
|
||||
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
|
||||
正式部署時使用 HTTPS,並設定反向代理支援串流回應與 WebSocket 升級。持久化並備份資料庫和掛載資料。多節點必須共用主資料庫和驗證金鑰;獨立 Redis 或記憶體限流器會按節點分別計數。不同拓撲下的工作階段傳播行為見驗證文件。
|
||||
|
||||
📖 更多部署方式請參考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
從[發行頁](https://github.com/QuantumNous/new-api/releases)選擇明確的映像版本,閱讀升級說明並先備份再升級。`latest` 會隨發行建置變動;既有執行個體應依實際版本評估遷移與相容性。
|
||||
|
||||
---
|
||||
<a id="development"></a>
|
||||
|
||||
## 📚 文件
|
||||
## 開發與擴充
|
||||
|
||||
<div align="center">
|
||||
後端使用 Go 和 Gin;控制台使用 React 19、TypeScript、Rsbuild、TanStack 與 Tailwind CSS 4。前端相依套件和指令碼使用 Bun;Go 語言基線見 [go.mod](./go.mod),容器建置工具鏈見 [Dockerfile](./Dockerfile)。
|
||||
|
||||
### 📖 [官方文件](https://docs.newapi.pro/zh/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**快速導航:**
|
||||
|
||||
| 分類 | 連結 |
|
||||
|------|------|
|
||||
| 🚀 部署指南 | [安裝文件](https://docs.newapi.pro/zh/docs/installation) |
|
||||
| ⚙️ 環境配置 | [環境變數](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
|
||||
| 📡 接口文件 | [API 文件](https://docs.newapi.pro/zh/docs/api) |
|
||||
| ❓ 常見問題 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
|
||||
| 💬 社群交流 | [交流管道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
> 詳細特性請參考 [特性說明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
|
||||
|
||||
### 🎨 核心功能
|
||||
|
||||
| 特性 | 說明 |
|
||||
|------|------|
|
||||
| 🎨 全新 UI | 現代化的用戶界面設計 |
|
||||
| 🌍 多語言 | 支援簡體中文、繁體中文、英文、法語、日語 |
|
||||
| 🔄 數據兼容 | 完全兼容原版 One API 資料庫 |
|
||||
| 📈 數據看板 | 視覺化控制檯與統計分析 |
|
||||
| 🔒 權限管理 | 令牌分組、模型限制、用戶管理 |
|
||||
|
||||
### 💰 授權用量與成本管理
|
||||
|
||||
- ✅ 合法授權場景下的內部儲值與額度分配(易支付、Stripe)
|
||||
- ✅ 組織內按次、按量或快取命中成本核算
|
||||
- ✅ 支援 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的快取計費統計
|
||||
- ✅ 面向內部管理或企業客戶的靈活計費策略配置
|
||||
|
||||
### 🔐 授權與安全
|
||||
|
||||
- 😈 Discord 授權登錄
|
||||
- 🤖 LinuxDO 授權登錄
|
||||
- 📱 Telegram 授權登錄
|
||||
- 🔑 OIDC 統一認證
|
||||
- 🔍 Key 查詢使用額度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高級功能
|
||||
|
||||
**API 格式支援:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
|
||||
- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
|
||||
- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
|
||||
|
||||
**智慧路由:**
|
||||
- ⚖️ 管道加權隨機
|
||||
- 🔄 失敗自動重試
|
||||
- 🚦 用戶級別模型限流
|
||||
|
||||
**格式轉換:**
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - 僅支援文本,暫不支援函數調用
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 開發中
|
||||
- 🔄 **思考轉內容功能**
|
||||
|
||||
**Reasoning Effort 支援:**
|
||||
|
||||
<details>
|
||||
<summary>查看詳細配置</summary>
|
||||
|
||||
**OpenAI 系列模型:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude 思考模型:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - 啟用思考模式
|
||||
|
||||
**Google Gemini 系列模型:**
|
||||
- `gemini-2.5-flash-thinking` - 啟用思考模式
|
||||
- `gemini-2.5-flash-nothinking` - 禁用思考模式
|
||||
- `gemini-2.5-pro-thinking` - 啟用思考模式
|
||||
- `gemini-2.5-pro-thinking-128` - 啟用思考模式,並設置思考預算為128tokens
|
||||
- 也可以直接在 Gemini 模型名稱後追加 `-low` / `-medium` / `-high` 來控制思考力道(無需再設置思考預算後綴)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 模型支援
|
||||
|
||||
> 詳情請參考 [接口文件 - 閘道接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型類型 | 說明 | 文件 |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI-Compatible | OpenAI 兼容模型 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion) |
|
||||
| 🤖 OpenAI Responses | OpenAI Responses 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse) |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文件](https://doc.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文件](https://doc.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere、Jina | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
|
||||
| 💬 Claude | Messages 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自訂上游 | 支援配置合法授權的上游介面位址 | - |
|
||||
|
||||
### 📡 支援的接口
|
||||
|
||||
<details>
|
||||
<summary>查看完整接口列表</summary>
|
||||
|
||||
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion)
|
||||
- [響應接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
|
||||
- [圖像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
|
||||
- [音訊接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
|
||||
- [影片接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
|
||||
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
|
||||
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
|
||||
- [即時對話 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
|
||||
- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage)
|
||||
- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 部署
|
||||
|
||||
> [!TIP]
|
||||
> **最新版 Docker 鏡像:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 部署要求
|
||||
|
||||
| 組件 | 要求 |
|
||||
|------|------|
|
||||
| **本地資料庫** | SQLite(Docker 需掛載 `/data` 目錄)|
|
||||
| **遠端資料庫** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
|
||||
| **容器引擎** | Docker / Docker Compose |
|
||||
| **系統架構** | 僅支援 64 位元系統(amd64 / arm64),不支援 32 位元系統 |
|
||||
|
||||
### ⚙️ 環境變數配置
|
||||
|
||||
<details>
|
||||
<summary>常用環境變數配置</summary>
|
||||
|
||||
| 變數名 | 說明 | 預設值 |
|
||||
|--------|--------------------------------------------------------------|--------|
|
||||
| `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - |
|
||||
| `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` |
|
||||
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - |
|
||||
| `TRUSTED_PROXIES` | 未設定/留空時信任本機回送、RFC1918 和 IPv6 ULA 並輸出啟動警告;`none` 不信任任何代理;明確指定的代理 IP/CIDR 清單會完整取代預設值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
|
||||
| `USER_SESSION_ACTIVE_LIMIT` | 單一用戶最大活躍登入 Session 數 | `50` |
|
||||
| `USER_SESSION_ISSUANCE_LIMIT` | 單一用戶在簽發視窗內可建立的 Session 總數,包含已撤銷 Session | `100` |
|
||||
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 簽發計數視窗(秒);高於 revoked 保留期時自動限制 | `86400` |
|
||||
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用於稽核與簽發計數的保留天數 | `7` |
|
||||
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全域每小時 Session 簽發告警門檻;只告警,不拒絕登入 | `5000` |
|
||||
| `CRYPTO_SECRET` | 快取鍵 HMAC 密鑰;共用 Redis 的節點必須使用相同有效值 | 預設跟隨 `SESSION_SECRET` |
|
||||
| `SQL_DSN` | 資料庫連接字符串 | - |
|
||||
| `REDIS_CONN_STRING` | Redis 連接字符串 | - |
|
||||
| `STREAMING_TIMEOUT` | 流式超時時間(秒) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式掃描器單行最大緩衝(MB),圖像生成等超大 `data:` 片段(如 4K 圖片 base64)需適當調大 | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | 請求體最大大小(MB,**解壓縮後**計;防止超大請求/zip bomb 導致記憶體暴漲),超過將返回 `413` | `32` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | 錯誤日誌開關 | `false` |
|
||||
| `PYROSCOPE_URL` | Pyroscope 服務位址 | - |
|
||||
| `PYROSCOPE_APP_NAME` | Pyroscope 應用名 | `new-api` |
|
||||
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用戶名 | - |
|
||||
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密碼 | - |
|
||||
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 採樣率 | `5` |
|
||||
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 採樣率 | `5` |
|
||||
| `HOSTNAME` | Pyroscope 標籤裡的主機名 | `new-api` |
|
||||
|
||||
📖 **完整配置:** [環境變數文件](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 部署方式
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 1:Docker Compose(推薦)</strong></summary>
|
||||
後端會嵌入 `web/dist`,首次啟動前先建置前端:
|
||||
|
||||
```bash
|
||||
# 複製項目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 編輯配置
|
||||
nano docker-compose.yml
|
||||
|
||||
# 啟動服務
|
||||
docker-compose up -d
|
||||
# 儲存庫根目錄
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
cd ..
|
||||
go run .
|
||||
```
|
||||
|
||||
</details>
|
||||
在另一個終端機啟動前端開發伺服器:
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 2:Docker 命令</strong></summary>
|
||||
|
||||
**使用 SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
cd web
|
||||
bun run dev -- --port 5173
|
||||
```
|
||||
|
||||
**使用 MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
存取 [http://localhost:5173](http://localhost:5173),開發伺服器會將 API 請求代理到 3000 連接埠的後端。需要容器化開發後端時,參閱 [docker-compose.dev.yml](./docker-compose.dev.yml) 和 [makefile](./makefile) 中的 `make dev`。
|
||||
|
||||
> **💡 路徑說明:**
|
||||
> - `./data:/data` - 相對路徑,數據保存在當前目錄的 data 資料夾
|
||||
> - 也可使用絕對路徑,如:`/your/custom/path:/data`
|
||||
| 目錄 | 職責 |
|
||||
| --- | --- |
|
||||
| `router/`、`middleware/`、`controller/` | HTTP 路由、存取驗證與 API 處理 |
|
||||
| `relay/` | 上游介接與請求調度 |
|
||||
| `service/`、`model/` | 業務邏輯與持久化 |
|
||||
| [relaykit/](./relaykit/README.md) | 可獨立建置的協定 DTO 與轉換 Go 模組 |
|
||||
| [plugins/tasks/](./plugins/tasks/) | JavaScript 任務外掛;編寫方式與宿主邊界見 [Task Plugin API v1](./docs/plugin-api/v1.md) |
|
||||
| `web/` | Web 控制台,參閱[前端開發約定](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | 桌面封裝與打包 |
|
||||
|
||||
</details>
|
||||
貢獻前請閱讀 [AGENTS.md](./AGENTS.md)。依改動範圍執行檢查:Go 模組使用 `make test`;前端在 `web/` 下執行 `bun run typecheck`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 後還必須在 `relaykit/` 下執行 `GOWORK=off go build ./...`。
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 3:寶塔面板</strong></summary>
|
||||
<a id="documentation"></a>
|
||||
|
||||
1. 安裝寶塔面板(≥ 9.2.0 版本)
|
||||
2. 在應用商店搜尋 **New-API**
|
||||
3. 一鍵安裝
|
||||
## 文件與社群
|
||||
|
||||
📖 [圖文教學](./docs/BT.md)
|
||||
| 資源 | 入口 |
|
||||
| --- | --- |
|
||||
| 官方文件 | [使用指南](https://docs.newapi.ai/zh/docs) · [安裝部署](https://docs.newapi.ai/zh/docs/installation) · [API 參考](https://docs.newapi.ai/zh/docs/api) |
|
||||
| 專案導讀 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
|
||||
| 使用問題與交流 | [常見問題](https://docs.newapi.ai/zh/docs/support/faq) · [社群管道](https://docs.newapi.ai/zh/docs/support/community-interaction) |
|
||||
| 缺陷與功能建議 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
|
||||
| 安全漏洞 | 依[安全政策](./.github/SECURITY.md)私下回報 |
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ 多機部署注意事項
|
||||
|
||||
> [!WARNING]
|
||||
> - 所有節點必須使用同一個主資料庫,並設定相同的 `SESSION_SECRET`;否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證。
|
||||
> - 連線至同一個 Redis 的節點還必須設定相同的 `CRYPTO_SECRET`,否則節點產生的快取鍵摘要不一致,無法正確共用快取。
|
||||
|
||||
登入 Session 和單一使用者的活躍數/簽發數限制均以資料庫為權威。Redis 中的 Session 僅為短期快取,TTL 跟隨 `SYNC_FREQUENCY`(預設 60 秒),且不會超過 Session 的剩餘有效期。
|
||||
|
||||
| Redis 拓撲 | Session 狀態傳播 | 限流語義 |
|
||||
| --- | --- | --- |
|
||||
| 所有節點共用 Redis | 撤銷和版本發布通常即時傳播 | Redis 限流額度在節點間共用 |
|
||||
| 每個節點使用獨立 Redis | 最遲在有效 `SYNC_FREQUENCY` 內回源資料庫並收斂;版本輪換後,新 Token 在持有舊快取的節點上可能短暫傳回 401 | 每個節點獨立計數,叢集總額度最壞約為單一節點門檻乘以節點數 |
|
||||
| 不使用 Redis | 每次 Session 驗證都直接讀取資料庫 | 各節點使用獨立的記憶體限流額度 |
|
||||
|
||||
縮短 `SYNC_FREQUENCY` 可減少獨立 Redis 的陳舊視窗,但每個活躍 SID 在每個節點上會依該 TTL 增加一次資料庫主鍵查詢。上述保證只讓 Session 鑑權在不同拓撲下維持有界陳舊;限流和其他 Redis 控制面快取仍受拓撲影響。
|
||||
|
||||
Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階段](./docs/authentication.md)。
|
||||
|
||||
### 🔄 管道重試與快取
|
||||
|
||||
**重試配置:** `設置 → 運營設置 → 通用設置 → 失敗重試次數`
|
||||
|
||||
**快取配置:**
|
||||
- `REDIS_CONN_STRING`:Redis 快取(推薦)
|
||||
- `MEMORY_CACHE_ENABLED`:記憶體快取
|
||||
回報問題時請附上版本、部署方式、重現步驟及去識別化日誌。歡迎貢獻文件、翻譯、渠道介接和有針對性的回歸測試。
|
||||
|
||||
---
|
||||
|
||||
@@ -446,36 +302,18 @@ Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階
|
||||
|
||||
---
|
||||
|
||||
## 💬 幫助支援
|
||||
|
||||
### 📖 文件資源
|
||||
|
||||
| 資源 | 連結 |
|
||||
|------|------|
|
||||
| 📘 常見問題 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
|
||||
| 💬 社群交流 | [交流管道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
|
||||
| 🐛 回饋問題 | [問題回饋](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
|
||||
| 📚 完整文件 | [官方文件](https://docs.newapi.pro/zh/docs) |
|
||||
|
||||
### 🤝 貢獻指南
|
||||
|
||||
歡迎各種形式的貢獻!
|
||||
|
||||
- 🐛 報告 Bug
|
||||
- 💡 提出新功能
|
||||
- 📝 改進文件
|
||||
- 🔧 提交程式碼
|
||||
|
||||
---
|
||||
|
||||
## 📜 許可證
|
||||
|
||||
本項目採用 [GNU Affero 通用公共許可證 v3.0 (AGPLv3)](./LICENSE) 授權。
|
||||
|
||||
依據 AGPLv3 第 7 條,本專案另適用[附加條款](./NOTICE)。修改版本必須在適當法律聲明及介面中顯著的關於、法律、頁尾或署名位置保留作者署名 `Frontend design and development by New API contributors.`,並保留指向原專案 <https://github.com/QuantumNous/new-api> 的可見連結。
|
||||
|
||||
本項目為開源項目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 許可證)的基礎上進行二次開發。
|
||||
|
||||
如果您所在的組織政策不允許使用 AGPLv3 許可的軟體,或您希望規避 AGPLv3 的開源義務,請發送郵件至:[support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
署名及相依套件聲明見 [NOTICE](./NOTICE) 和[第三方授權條款](./THIRD-PARTY-LICENSES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
@@ -494,7 +332,7 @@ Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階
|
||||
|
||||
如果這個項目對你有幫助,歡迎給我們一個 ⭐️ Star!
|
||||
|
||||
**[官方文件](https://docs.newapi.pro/zh/docs)** • **[問題回饋](https://github.com/Calcium-Ion/new-api/issues)** • **[最新發布](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
**[官方文件](https://docs.newapi.ai/zh/docs)** • **[問題回饋](https://github.com/Calcium-Ion/new-api/issues)** • **[最新發布](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
|
||||
@@ -3,147 +3,171 @@
|
||||
This file summarizes direct third-party dependencies used by distributed builds of this project.
|
||||
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
|
||||
|
||||
Scope: direct dependencies from `go.mod`, `web/package.json`, and `electron/package.json`.
|
||||
Scope: direct dependencies from `go.mod` (including the in-repo `relaykit` and `tokenkit` modules), `web/package.json`, and `electron/package.json`.
|
||||
Transitive dependencies should be audited before a final external release.
|
||||
|
||||
## Dependency Inventory
|
||||
|
||||
| Area | Scope | Ecosystem | Dependency | Version | License |
|
||||
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer |
|
||||
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
|
||||
| backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT |
|
||||
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT |
|
||||
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
|
||||
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
|
||||
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
|
||||
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/grafana/sobek` | `v0.0.0-20260708062710-267a0e055bb4` | MIT |
|
||||
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
|
||||
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
|
||||
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
|
||||
| backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
|
||||
| backend | production | Go | `github.com/samber/lo` | `v1.52.0` | MIT |
|
||||
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/stretchr/testify` | `v1.11.1` | MIT |
|
||||
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
|
||||
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/gjson` | `v1.18.0` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
|
||||
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.6.2` | MIT |
|
||||
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.1` | MIT |
|
||||
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
|
||||
| backend | production | Go | `golang.org/x/crypto` | `v0.45.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/image` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/net` | `v0.47.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sys` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/text` | `v0.35.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
|
||||
| backend | production | Go | `gorm.io/driver/mysql` | `v1.4.3` | MIT |
|
||||
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT |
|
||||
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
|
||||
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
|
||||
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
|
||||
| web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT |
|
||||
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
|
||||
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
|
||||
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
|
||||
| web | production | npm | `@codemirror/view` | `6.43.6` | MIT |
|
||||
| web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 |
|
||||
| web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 |
|
||||
| web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT |
|
||||
| web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT |
|
||||
| web | production | npm | `@hugeicons/react` | `1.1.9` | MIT |
|
||||
| web | production | npm | `@lezer/highlight` | `1.2.3` | MIT |
|
||||
| web | production | npm | `@lobehub/icons` | `5.14.0` | MIT |
|
||||
| web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT |
|
||||
| web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT |
|
||||
| web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
|
||||
| web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT |
|
||||
| web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT |
|
||||
| web | production | npm | `@visactor/vchart` | `2.1.4` | MIT |
|
||||
| web | production | npm | `ai` | `7.0.31` | Apache-2.0 |
|
||||
| web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
|
||||
| web | production | npm | `axios` | `1.18.1` | MIT |
|
||||
| web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
|
||||
| web | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web | production | npm | `cmdk` | `1.1.1` | MIT |
|
||||
| web | production | npm | `dayjs` | `1.11.21` | MIT |
|
||||
| web | production | npm | `dompurify` | `3.4.11` | Apache-2.0 OR MPL-2.0 |
|
||||
| web | production | npm | `i18next` | `26.3.6` | MIT |
|
||||
| web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
|
||||
| web | production | npm | `input-otp` | `1.4.2` | MIT |
|
||||
| web | production | npm | `katex` | `0.17.0` | MIT |
|
||||
| web | production | npm | `lucide-react` | `1.25.0` | ISC |
|
||||
| web | production | npm | `marked` | `18.0.6` | MIT |
|
||||
| web | production | npm | `motion` | `12.42.2` | MIT |
|
||||
| web | production | npm | `nanoid` | `5.1.16` | MIT |
|
||||
| web | production | npm | `next-themes` | `0.4.6` | MIT |
|
||||
| web | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web | production | npm | `react` | `19.2.7` | MIT |
|
||||
| web | production | npm | `react-day-picker` | `10.0.1` | MIT |
|
||||
| web | production | npm | `react-dom` | `19.2.7` | MIT |
|
||||
| web | production | npm | `react-hook-form` | `7.82.0` | MIT |
|
||||
| web | production | npm | `react-i18next` | `17.0.10` | MIT |
|
||||
| web | production | npm | `react-icons` | `5.7.0` | MIT |
|
||||
| web | production | npm | `react-resizable-panels` | `4.12.2` | MIT |
|
||||
| web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
|
||||
| web | production | npm | `recharts` | `3.9.1` | MIT |
|
||||
| web | production | npm | `shiki` | `4.3.1` | MIT |
|
||||
| web | production | npm | `sonner` | `2.0.7` | MIT |
|
||||
| web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
|
||||
| web | production | npm | `stream-markdown-parser` | `1.1.3` | MIT |
|
||||
| web | production | npm | `tailwind-merge` | `3.6.0` | MIT |
|
||||
| web | production | npm | `tailwindcss` | `4.3.3` | MIT |
|
||||
| web | production | npm | `tokenlens` | `1.3.1` | MIT |
|
||||
| web | production | npm | `tw-animate-css` | `1.4.0` | MIT |
|
||||
| web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT |
|
||||
| web | production | npm | `vaul` | `1.1.2` | MIT |
|
||||
| web | production | npm | `zod` | `4.4.3` | MIT |
|
||||
| web | production | npm | `zustand` | `5.0.14` | MIT |
|
||||
| web | development | npm | `@rsbuild/core` | `2.1.6` | MIT |
|
||||
| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT |
|
||||
| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT |
|
||||
| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT |
|
||||
| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT |
|
||||
| web | development | npm | `@tanstack/router-plugin` | `1.168.23` | MIT |
|
||||
| web | development | npm | `@types/node` | `26.1.1` | MIT |
|
||||
| web | development | npm | `@types/react` | `19.2.17` | MIT |
|
||||
| web | development | npm | `@types/react-dom` | `19.2.3` | MIT |
|
||||
| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 |
|
||||
| web | development | npm | `@xyflow/react` | `12.11.2` | MIT |
|
||||
| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
|
||||
| web | development | npm | `knip` | `6.27.0` | ISC |
|
||||
| web | development | npm | `oxfmt` | `0.57.0` | MIT |
|
||||
| web | development | npm | `oxlint` | `1.74.0` | MIT |
|
||||
| web | development | npm | `shadcn` | `4.13.1` | MIT |
|
||||
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
|
||||
| electron | development | npm | `electron` | `39.8.5` | MIT |
|
||||
| electron | development | npm | `electron-builder` | `26.7.0` | MIT |
|
||||
| Area | Scope | Ecosystem | Dependency | Version | License |
|
||||
|----------|-------------|-----------|---------------------------------------------------------|--------------------------------------|-------------------------|
|
||||
| backend | production | Go | `github.com/Azure/go-ntlmssp` | `v0.1.1` | MIT |
|
||||
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/Calcium-Ion/moejs` | `v0.1.0-alpha.7` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/ClickHouse/clickhouse-go/v2` | `v2.46.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
|
||||
| backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT |
|
||||
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.2.0` | MIT |
|
||||
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` | `v1.7.8` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/casbin/casbin/v2` | `v2.135.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/coreos/go-oidc/v3` | `v3.21.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
|
||||
| backend | production | Go | `github.com/fxamacker/cbor/v2` | `v2.9.0` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
|
||||
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
|
||||
| backend | production | Go | `github.com/glebarez/go-sqlite` | `v1.21.2` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.11.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/go-sql-driver/mysql` | `v1.7.0` | MPL-2.0 |
|
||||
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
|
||||
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.3` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/jackc/pgx/v5` | `v5.9.2` | MIT |
|
||||
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
|
||||
| backend | test | Go | `github.com/json-iterator/go` | `v1.1.12` | MIT |
|
||||
| backend | production | Go | `github.com/klauspost/compress` | `v1.18.3` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
|
||||
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
|
||||
| backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/prometheus/common` | `v0.62.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
|
||||
| backend | production | Go | `github.com/samber/lo` | `v1.53.0` | MIT |
|
||||
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
|
||||
| backend | test | Go | `github.com/stretchr/testify` | `v1.12.1` | MIT |
|
||||
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
|
||||
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/gjson` | `v1.19.0` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
|
||||
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.8.1` | MIT |
|
||||
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.2` | MIT |
|
||||
| backend | production | Go | `github.com/waffo-com/waffo-pancake-sdk-go` | `v0.3.1` | MIT |
|
||||
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
|
||||
| backend | production | Go | `golang.org/x/crypto` | `v0.52.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/image` | `v0.41.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/net` | `v0.55.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/oauth2` | `v0.36.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sys` | `v0.45.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/text` | `v0.37.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
|
||||
| backend | production | Go | `gorm.io/driver/clickhouse` | `v0.6.0` | MIT |
|
||||
| backend | production | Go | `gorm.io/driver/mysql` | `v1.5.7` | MIT |
|
||||
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.9` | MIT |
|
||||
| backend | production | Go | `gorm.io/gorm` | `v1.25.12` | MIT |
|
||||
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
|
||||
| web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT |
|
||||
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
|
||||
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
|
||||
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
|
||||
| web | production | npm | `@codemirror/view` | `6.43.6` | MIT |
|
||||
| web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 |
|
||||
| web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 |
|
||||
| web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT |
|
||||
| web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT |
|
||||
| web | production | npm | `@hugeicons/react` | `1.1.9` | MIT |
|
||||
| web | production | npm | `@lezer/highlight` | `1.2.3` | MIT |
|
||||
| web | production | npm | `@lobehub/icons` | `5.22.0` | MIT |
|
||||
| web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT |
|
||||
| web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT |
|
||||
| web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
|
||||
| web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT |
|
||||
| web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT |
|
||||
| web | production | npm | `@visactor/vchart` | `2.1.4` | MIT |
|
||||
| web | production | npm | `ai` | `7.0.136` | Apache-2.0 |
|
||||
| web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
|
||||
| web | production | npm | `axios` | `1.18.1` | MIT |
|
||||
| web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
|
||||
| web | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web | production | npm | `cmdk` | `1.1.1` | MIT |
|
||||
| web | production | npm | `dayjs` | `1.11.21` | MIT |
|
||||
| web | production | npm | `dompurify` | `3.4.16` | Apache-2.0 OR MPL-2.0 |
|
||||
| web | production | npm | `i18next` | `26.3.6` | MIT |
|
||||
| web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
|
||||
| web | production | npm | `input-otp` | `1.4.2` | MIT |
|
||||
| web | production | npm | `katex` | `0.18.2` | MIT |
|
||||
| web | production | npm | `lucide-react` | `1.25.0` | ISC |
|
||||
| web | production | npm | `marked` | `18.0.6` | MIT |
|
||||
| web | production | npm | `motion` | `12.42.2` | MIT |
|
||||
| web | production | npm | `nanoid` | `5.1.16` | MIT |
|
||||
| web | production | npm | `next-themes` | `0.4.6` | MIT |
|
||||
| web | production | npm | `node-forge` | `1.4.0` | BSD-3-Clause OR GPL-2.0 |
|
||||
| web | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web | production | npm | `react` | `19.2.7` | MIT |
|
||||
| web | production | npm | `react-day-picker` | `10.0.1` | MIT |
|
||||
| web | production | npm | `react-dom` | `19.2.7` | MIT |
|
||||
| web | production | npm | `react-hook-form` | `7.82.0` | MIT |
|
||||
| web | production | npm | `react-i18next` | `17.0.10` | MIT |
|
||||
| web | production | npm | `react-icons` | `5.7.0` | MIT |
|
||||
| web | production | npm | `react-resizable-panels` | `4.12.2` | MIT |
|
||||
| web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
|
||||
| web | production | npm | `recharts` | `3.9.1` | MIT |
|
||||
| web | production | npm | `shiki` | `4.3.1` | MIT |
|
||||
| web | production | npm | `sonner` | `2.0.7` | MIT |
|
||||
| web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
|
||||
| web | production | npm | `stream-markdown-parser` | `1.2.18` | MIT |
|
||||
| web | production | npm | `tailwind-merge` | `3.6.0` | MIT |
|
||||
| web | production | npm | `tailwindcss` | `4.3.3` | MIT |
|
||||
| web | production | npm | `tokenlens` | `1.3.1` | MIT |
|
||||
| web | production | npm | `tw-animate-css` | `1.4.0` | MIT |
|
||||
| web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT |
|
||||
| web | production | npm | `vaul` | `1.1.2` | MIT |
|
||||
| web | production | npm | `yace` | `1.1.1` | MIT |
|
||||
| web | production | npm | `yaml` | `2.9.0` | ISC |
|
||||
| web | production | npm | `zod` | `4.4.3` | MIT |
|
||||
| web | production | npm | `zustand` | `5.0.14` | MIT |
|
||||
| web | development | npm | `@rsbuild/core` | `2.1.6` | MIT |
|
||||
| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT |
|
||||
| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT |
|
||||
| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT |
|
||||
| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT |
|
||||
| web | development | npm | `@tanstack/router-plugin` | `1.168.42` | MIT |
|
||||
| web | development | npm | `@testing-library/jest-dom` | `7.0.0` | MIT |
|
||||
| web | development | npm | `@testing-library/react` | `16.3.2` | MIT |
|
||||
| web | development | npm | `@testing-library/user-event` | `14.6.1` | MIT |
|
||||
| web | development | npm | `@types/node` | `26.1.1` | MIT |
|
||||
| web | development | npm | `@types/node-forge` | `1.3.14` | MIT |
|
||||
| web | development | npm | `@types/react` | `19.2.17` | MIT |
|
||||
| web | development | npm | `@types/react-dom` | `19.2.3` | MIT |
|
||||
| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 |
|
||||
| web | development | npm | `@xyflow/react` | `12.11.2` | MIT |
|
||||
| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
|
||||
| web | development | npm | `jsdom` | `29.1.1` | MIT |
|
||||
| web | development | npm | `knip` | `6.27.0` | ISC |
|
||||
| web | development | npm | `oxfmt` | `0.72.0` | MIT |
|
||||
| web | development | npm | `oxlint` | `1.74.0` | MIT |
|
||||
| web | development | npm | `shadcn` | `4.13.1` | MIT |
|
||||
| web | development | npm | `vitest` | `4.1.10` | MIT |
|
||||
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
|
||||
| electron | development | npm | `electron` | `41.10.6` | MIT |
|
||||
| electron | development | npm | `electron-builder` | `26.15.3` | MIT |
|
||||
|
||||
## License Texts
|
||||
|
||||
@@ -297,6 +321,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### MPL-2.0
|
||||
|
||||
Mozilla Public License 2.0
|
||||
https://mozilla.org/MPL/2.0/
|
||||
|
||||
The source code of the MPL-covered files is available from the upstream project at https://github.com/go-sql-driver/mysql.
|
||||
|
||||
### OFL-1.1
|
||||
|
||||
SIL Open Font License 1.1
|
||||
@@ -308,10 +339,6 @@ https://openfontlicense.org/open-font-license-official-text/
|
||||
When distributing font files, preserve the OFL license text, copyright notices,
|
||||
and reserved font name restrictions supplied by the upstream font project.
|
||||
|
||||
### Proprietary/Internal - owned by project maintainer
|
||||
|
||||
This dependency is owned by the project maintainer and is not treated as a third-party open source dependency for this review.
|
||||
|
||||
### Unlicense
|
||||
|
||||
The Unlicense
|
||||
@@ -322,3 +349,14 @@ this software, either in source code form or as a compiled binary, for any
|
||||
purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
For more information, please refer to https://unlicense.org/
|
||||
|
||||
## Numerical model fingerprint assets
|
||||
|
||||
The embedded bank and scorer in `pkg/model_fingerprint` are from [gpt-model-check](https://github.com/Katao123/gpt-model-check), commit `3d6716a0903cf061daea001d5d44ee410846634e`, using the ModelTrace bank dated 2026-09-05. The upstream gpt-model-check, ModelTrace, and is-gpt-nerfed MIT notices are included alongside the sources and under `/licenses/model-fingerprint/` in the image. Relative candidate weights are not calibrated identity probabilities.
|
||||
|
||||
## Meow model fingerprint
|
||||
|
||||
The active short-probe benchmark and predictive scorer are adapted from chen-006/meow-llm-detector, commit 0f31d4a, benchmark 4.5.4-predictive.20260924.2 (quick tier, 32 requests). License: PolyForm Noncommercial 1.0.0; see pkg/model_fingerprint/LICENSE-meow.txt. Required Notice: Copyright 2026 chen-006 and contributors. Original project: https://github.com/chen-006/gpt56_api_detector. Match scores are not identity probabilities.
|
||||
|
||||
ModelTrace primary fingerprint: https://github.com/xqy2006/ModelTrace, commit d4131b30243dfa05e70180b5eedde742103f1d73, MIT. Scorer and unified candidate bank are embedded locally; see pkg/model_fingerprint/LICENSE-ModelTrace.txt.
|
||||
Meow 4.5.4 predictive benchmark remains an auxiliary signal; see pkg/model_benchmark/LICENSE-meow.txt.
|
||||
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -1,6 +0,0 @@
|
||||
UPDATE users
|
||||
SET quota = quota + (
|
||||
SELECT SUM(remain_quota)
|
||||
FROM tokens
|
||||
WHERE tokens.user_id = users.id
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
INSERT INTO abilities (`group`, model, channel_id, enabled)
|
||||
SELECT c.`group`, m.model, c.id, 1
|
||||
FROM channels c
|
||||
CROSS JOIN (
|
||||
SELECT 'gpt-3.5-turbo' AS model UNION ALL
|
||||
SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
|
||||
SELECT 'gpt-4' AS model UNION ALL
|
||||
SELECT 'gpt-4-0314' AS model
|
||||
) AS m
|
||||
WHERE c.status = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM abilities a
|
||||
WHERE a.`group` = c.`group`
|
||||
AND a.model = m.model
|
||||
AND a.channel_id = c.id
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ $# -lt 3 ]; then
|
||||
echo "Usage: time_test.sh <domain> <key> <count> [<model>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domain=$1
|
||||
key=$2
|
||||
count=$3
|
||||
model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
|
||||
|
||||
total_time=0
|
||||
times=()
|
||||
|
||||
for ((i=1; i<=count; i++)); do
|
||||
result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
|
||||
https://"$domain"/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $key" \
|
||||
-d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
|
||||
http_code=$(echo "$result" | awk '{print $1}')
|
||||
time=$(echo "$result" | awk '{print $2}')
|
||||
echo "HTTP status code: $http_code, Time taken: $time"
|
||||
total_time=$(bc <<< "$total_time + $time")
|
||||
times+=("$time")
|
||||
done
|
||||
|
||||
average_time=$(echo "scale=4; $total_time / $count" | bc)
|
||||
|
||||
sum_of_squares=0
|
||||
for time in "${times[@]}"; do
|
||||
difference=$(echo "scale=4; $time - $average_time" | bc)
|
||||
square=$(echo "scale=4; $difference * $difference" | bc)
|
||||
sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
|
||||
done
|
||||
|
||||
standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
|
||||
|
||||
echo "Average time: $average_time±$standard_deviation"
|
||||
@@ -0,0 +1,28 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
)
|
||||
|
||||
// GetAdvancedCustomPreset returns fresh defaults.
|
||||
func GetAdvancedCustomPreset(channelType int) *dto.AdvancedCustomConfig {
|
||||
if channelType != constant.ChannelTypeVLLM && channelType != constant.ChannelTypeSGLang {
|
||||
return nil
|
||||
}
|
||||
config := &dto.AdvancedCustomConfig{}
|
||||
for _, path := range []string{"/v1/chat/completions", "/v1/completions", "/v1/responses", "/v1/embeddings", "/v1/messages", dto.AdvancedCustomModelListPath} {
|
||||
config.Routes = append(config.Routes, dto.AdvancedCustomRoute{
|
||||
IncomingPath: path, UpstreamPath: path, Converter: "none",
|
||||
Auth: &dto.AdvancedCustomRouteAuth{Type: dto.AdvancedCustomAuthTypeHeader, Name: "Authorization", Value: "Bearer {api_key}"},
|
||||
})
|
||||
}
|
||||
if channelType == constant.ChannelTypeSGLang {
|
||||
for _, path := range []string{"/v1/rerank", "/rerank"} {
|
||||
config.Routes = append(config.Routes, dto.AdvancedCustomRoute{
|
||||
IncomingPath: path, UpstreamPath: "/v1/rerank", Converter: dto.AdvancedCustomConverterSGLangRerank,
|
||||
})
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
||||
apiType = constant.APITypeReplicate
|
||||
case constant.ChannelTypeCodex:
|
||||
apiType = constant.APITypeCodex
|
||||
case constant.ChannelTypeAdvancedCustom:
|
||||
case constant.ChannelTypeAdvancedCustom, constant.ChannelTypeVLLM, constant.ChannelTypeSGLang:
|
||||
apiType = constant.APITypeAdvancedCustom
|
||||
case constant.ChannelTypeSub2API:
|
||||
apiType = constant.APITypeSub2API
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/abema/go-mp4"
|
||||
"github.com/go-audio/aiff"
|
||||
@@ -19,7 +20,8 @@ import (
|
||||
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
|
||||
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
|
||||
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
|
||||
SysLog(LogText("GetAudioDuration: ext=%s", ext))
|
||||
ext = strings.ToLower(ext)
|
||||
// 根据文件扩展名选择解析器
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
@@ -44,7 +46,7 @@ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duratio
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported audio format: %s", ext)
|
||||
}
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
|
||||
SysLog(LogText("GetAudioDuration: duration=%f", duration))
|
||||
return duration, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalWAV builds a valid 16-bit PCM mono WAV with the given number of sample frames.
|
||||
func minimalWAV(sampleRate uint32, frames int) []byte {
|
||||
const numChannels = 1
|
||||
const bitsPerSample = 16
|
||||
dataSize := frames * numChannels * (bitsPerSample / 8)
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString("RIFF")
|
||||
binary.Write(buf, binary.LittleEndian, uint32(36+dataSize))
|
||||
buf.WriteString("WAVE")
|
||||
buf.WriteString("fmt ")
|
||||
binary.Write(buf, binary.LittleEndian, uint32(16))
|
||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
|
||||
binary.Write(buf, binary.LittleEndian, uint16(numChannels))
|
||||
binary.Write(buf, binary.LittleEndian, sampleRate)
|
||||
byteRate := sampleRate * numChannels * (bitsPerSample / 8)
|
||||
binary.Write(buf, binary.LittleEndian, byteRate)
|
||||
binary.Write(buf, binary.LittleEndian, uint16(numChannels*(bitsPerSample/8)))
|
||||
binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample))
|
||||
buf.WriteString("data")
|
||||
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
|
||||
buf.Write(make([]byte, dataSize))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestGetAudioDurationCaseInsensitiveExt(t *testing.T) {
|
||||
wav := minimalWAV(8000, 8000) // 1 second
|
||||
|
||||
for _, ext := range []string{".wav", ".WAV", ".Wav"} {
|
||||
d, err := GetAudioDuration(context.Background(), bytes.NewReader(wav), ext)
|
||||
if err != nil {
|
||||
t.Fatalf("ext %q: unexpected error: %v", ext, err)
|
||||
}
|
||||
if d < 0.99 || d > 1.01 {
|
||||
t.Fatalf("ext %q: expected ~1s duration, got %v", ext, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAudioDurationUppercaseNotUnsupported(t *testing.T) {
|
||||
_, err := GetAudioDuration(context.Background(), bytes.NewReader([]byte("not audio")), ".MP3")
|
||||
if err != nil && strings.Contains(err.Error(), "unsupported audio format") {
|
||||
t.Fatalf("uppercase extension rejected as unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func CreateBodyStorage(data []byte) (BodyStorage, error) {
|
||||
storage, err := newDiskStorage(data, GetDiskCachePath())
|
||||
if err != nil {
|
||||
// 如果磁盘存储失败,回退到内存存储
|
||||
SysError(fmt.Sprintf("failed to create disk storage, falling back to memory: %v", err))
|
||||
SysError(LogText("failed to create disk storage, falling back to memory: %v", err))
|
||||
return newMemoryStorage(data), nil
|
||||
}
|
||||
return storage, nil
|
||||
|
||||
@@ -35,6 +35,11 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar
|
||||
var SessionSecret = uuid.New().String()
|
||||
var CryptoSecret = uuid.New().String()
|
||||
var SessionCookieSecure = false
|
||||
|
||||
// DefaultLanguage is the DEFAULT_LANGUAGE environment variable: the language of
|
||||
// server log lines and of text for a reader who states no language: en, zh-CN
|
||||
// or zh-TW. English when empty.
|
||||
var DefaultLanguage string
|
||||
var SessionCookieTrustedURLs []string
|
||||
|
||||
const (
|
||||
@@ -128,7 +133,11 @@ var QuotaForInvitee = 0
|
||||
var ChannelDisableThreshold = 5.0
|
||||
var AutomaticDisableChannelEnabled = false
|
||||
var AutomaticEnableChannelEnabled = false
|
||||
var QuotaRemindEnabled = true
|
||||
var QuotaRemindThreshold = 1000
|
||||
|
||||
// PreConsumedQuota is retained for old option clients; token reservations now
|
||||
// use quota_setting.pre_consume_multiplier and the estimated input cost.
|
||||
var PreConsumedQuota = 500
|
||||
|
||||
var RetryTimes = 0
|
||||
@@ -215,6 +224,10 @@ var (
|
||||
GlobalWebRateLimitNum int
|
||||
GlobalWebRateLimitDuration int64
|
||||
|
||||
GlobalStaticRateLimitEnable bool
|
||||
GlobalStaticRateLimitNum int
|
||||
GlobalStaticRateLimitDuration int64
|
||||
|
||||
CriticalRateLimitEnable bool
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 容器自身资源采样。
|
||||
//
|
||||
// 历史实现直接读取宿主机口径(/proc/stat 与 /proc/meminfo),把同节点其它
|
||||
// 工作负载的占用也算成本进程过载,导致正常的 relay 流量被 503 熔断。这里
|
||||
// 改为优先读取当前容器的 cgroup 配额与用量,只有在拿不到 cgroup 数据时才由
|
||||
// 调用方回退到宿主机口径。
|
||||
|
||||
const (
|
||||
defaultCgroupRoot = "/sys/fs/cgroup"
|
||||
// cgroup v1 用接近 int64 上限的值表示"未限制"。
|
||||
unlimitedMemoryBytes = uint64(1) << 60
|
||||
)
|
||||
|
||||
// containerUsage 记录上一次采样点,用来计算区间内的 CPU 占用。
|
||||
type containerUsage struct {
|
||||
cgroupRoot string
|
||||
// schedulableCPUs 是本进程可调度的逻辑 CPU 数(Linux 上含 cpuset/affinity 限制)。
|
||||
schedulableCPUs float64
|
||||
cpuLastUsec uint64
|
||||
cpuLastSampled time.Time
|
||||
}
|
||||
|
||||
func newContainerUsage() *containerUsage {
|
||||
// runtime.NumCPU 在 Linux 上于进程启动时按 sched_getaffinity 计数,不受 GOMAXPROCS 影响。
|
||||
return &containerUsage{cgroupRoot: defaultCgroupRoot, schedulableCPUs: float64(runtime.NumCPU())}
|
||||
}
|
||||
|
||||
// cpuSaturation 返回相对容器可用 CPU 的使用率(百分比)。
|
||||
// 可用 CPU 取 cgroup 配额与可调度 CPU 的较小者:配额大于 cpuset 或节点核数时,
|
||||
// 按配额计算会让容器打满也无法触发保护。
|
||||
// ok=false 表示当前环境没有可用的 cgroup CPU 数据,调用方应回退到宿主机口径。
|
||||
func (u *containerUsage) cpuSaturation(now time.Time) (float64, bool) {
|
||||
limitCores, ok := u.readCgroupCPUQuotaCores()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if u.schedulableCPUs > 0 {
|
||||
limitCores = min(limitCores, u.schedulableCPUs)
|
||||
}
|
||||
usageUsec, ok := u.readCgroupCPUUsageUsec()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if u.cpuLastSampled.IsZero() || usageUsec < u.cpuLastUsec || !now.After(u.cpuLastSampled) {
|
||||
u.cpuLastUsec = usageUsec
|
||||
u.cpuLastSampled = now
|
||||
return 0, false
|
||||
}
|
||||
elapsed := now.Sub(u.cpuLastSampled)
|
||||
if elapsed < time.Second {
|
||||
return 0, false
|
||||
}
|
||||
deltaUsec := usageUsec - u.cpuLastUsec
|
||||
u.cpuLastUsec = usageUsec
|
||||
u.cpuLastSampled = now
|
||||
|
||||
usedCores := float64(deltaUsec) / 1e6 / elapsed.Seconds()
|
||||
return usedCores / limitCores * 100, true
|
||||
}
|
||||
|
||||
// memoryUsage 返回容器内存工作集相对上限的使用率(百分比)。
|
||||
// 工作集 = 用量 - 非活跃文件缓存,与 docker stats、kubelet 一致:用量包含可回收的
|
||||
// 页缓存,写日志或文件后会长期贴近上限,不代表内存压力。
|
||||
// ok=false 时调用方回退宿主机口径(同样不含缓存):内存未限制、文件不可读、
|
||||
// 或 memory.stat 缺少非活跃缓存字段。宁可回退旧口径,也不把含缓存的用量当作压力。
|
||||
func (u *containerUsage) memoryUsage() (float64, bool) {
|
||||
usagePath := filepath.Join(u.cgroupRoot, "memory.current")
|
||||
limitPath := filepath.Join(u.cgroupRoot, "memory.max")
|
||||
statPath := filepath.Join(u.cgroupRoot, "memory.stat")
|
||||
inactiveFileKey := "inactive_file"
|
||||
if _, err := os.Stat(usagePath); err != nil {
|
||||
v1Dir := filepath.Join(u.cgroupRoot, "memory")
|
||||
usagePath = filepath.Join(v1Dir, "memory.usage_in_bytes")
|
||||
limitPath = filepath.Join(v1Dir, "memory.limit_in_bytes")
|
||||
statPath = filepath.Join(v1Dir, "memory.stat")
|
||||
inactiveFileKey = "total_inactive_file"
|
||||
}
|
||||
|
||||
// cgroup v2 未限制时 memory.max 为 "max",解析失败即视为不可用。
|
||||
limit, ok := readCgroupUint(limitPath)
|
||||
if !ok || limit == 0 || limit >= unlimitedMemoryBytes {
|
||||
return 0, false
|
||||
}
|
||||
usage, ok := readCgroupUint(usagePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
inactiveFile, ok := readCgroupStatValue(statPath, inactiveFileKey)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
// 用量与 memory.stat 不是同一时刻的快照,缓存可能短暂大于用量,按 0 处理防止下溢。
|
||||
workingSet := usage - min(inactiveFile, usage)
|
||||
return float64(workingSet) / float64(limit) * 100, true
|
||||
}
|
||||
|
||||
// readCgroupCPUQuotaCores 返回 cgroup CPU 配额(核)。ok=false 表示未限制或不可读。
|
||||
func (u *containerUsage) readCgroupCPUQuotaCores() (float64, bool) {
|
||||
if raw, err := os.ReadFile(filepath.Join(u.cgroupRoot, "cpu.max")); err == nil {
|
||||
quotaText, periodText, found := strings.Cut(strings.TrimSpace(string(raw)), " ")
|
||||
if !found || quotaText == "max" {
|
||||
return 0, false
|
||||
}
|
||||
quota, err := strconv.ParseUint(quotaText, 10, 64)
|
||||
if err != nil || quota == 0 {
|
||||
return 0, false
|
||||
}
|
||||
period, err := strconv.ParseUint(strings.TrimSpace(periodText), 10, 64)
|
||||
if err != nil || period == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(quota) / float64(period), true
|
||||
}
|
||||
|
||||
// cgroup v1 未限制时 cfs_quota_us 为 -1,解析失败即视为不可用。
|
||||
v1Dir := filepath.Join(u.cgroupRoot, "cpu")
|
||||
quota, ok := readCgroupUint(filepath.Join(v1Dir, "cpu.cfs_quota_us"))
|
||||
if !ok || quota == 0 {
|
||||
return 0, false
|
||||
}
|
||||
period, ok := readCgroupUint(filepath.Join(v1Dir, "cpu.cfs_period_us"))
|
||||
if !ok || period == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(quota) / float64(period), true
|
||||
}
|
||||
|
||||
func (u *containerUsage) readCgroupCPUUsageUsec() (uint64, bool) {
|
||||
statPath := filepath.Join(u.cgroupRoot, "cpu.stat")
|
||||
if _, err := os.Stat(statPath); err == nil {
|
||||
return readCgroupStatValue(statPath, "usage_usec")
|
||||
}
|
||||
// cgroup v1 的 cpuacct.usage 单位是纳秒。
|
||||
nanos, ok := readCgroupUint(filepath.Join(u.cgroupRoot, "cpuacct", "cpuacct.usage"))
|
||||
return nanos / 1000, ok
|
||||
}
|
||||
|
||||
// readCgroupUint 读取只含一个无符号整数的 cgroup 文件。
|
||||
func readCgroupUint(path string) (uint64, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
// readCgroupStatValue 从 "key value" 逐行格式的 cgroup 统计文件(cpu.stat、memory.stat)中读取一项。
|
||||
func readCgroupStatValue(path string, key string) (uint64, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
name, valueText, found := strings.Cut(line, " ")
|
||||
if !found || name != key {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(valueText), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
return err2
|
||||
}
|
||||
if SMTPServer == "" && SMTPAccount == "" {
|
||||
return fmt.Errorf("SMTP 服务器未配置")
|
||||
return NewMessage("SMTP server is not configured")
|
||||
}
|
||||
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
||||
mail := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||
@@ -130,7 +130,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
}
|
||||
err = client.Quit()
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
|
||||
SysError(LogText("failed to send email to %s: %v", receiver, err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -21,6 +21,8 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"},
|
||||
constant.EndpointTypeDeepSeekToolsSearch: {Path: "/v1/tools/search", Method: "POST"},
|
||||
constant.EndpointTypeDeepSeekToolsFetch: {Path: "/v1/tools/fetch", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
|
||||
|
||||
@@ -4,6 +4,13 @@ import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
||||
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
||||
// The DeepSeek Search and Fetch routing names serve only their endpoint.
|
||||
switch modelName {
|
||||
case constant.DeepSeekSearchModel:
|
||||
return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch}
|
||||
case constant.DeepSeekFetchModel:
|
||||
return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch}
|
||||
}
|
||||
var endpointTypes []constant.EndpointType
|
||||
switch channelType {
|
||||
case constant.ChannelTypeJina:
|
||||
@@ -28,6 +35,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
||||
case constant.ChannelTypeXai:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
|
||||
case constant.ChannelTypeVLLM, constant.ChannelTypeSGLang:
|
||||
endpointTypes = GetAdvancedCustomPreset(channelType).SupportedEndpointTypesForModel(modelName)
|
||||
case constant.ChannelTypeSora:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
|
||||
case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
@@ -12,7 +11,7 @@ func GetEnvOrDefault(env string, defaultValue int) int {
|
||||
}
|
||||
num, err := strconv.Atoi(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
||||
SysError(LogText("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return num
|
||||
@@ -31,7 +30,7 @@ func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
|
||||
}
|
||||
b, err := strconv.ParseBool(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
||||
SysError(LogText("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
|
||||
@@ -4,21 +4,26 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
const KeyRequestBody = "key_request_body"
|
||||
const KeyBodyStorage = "key_body_storage"
|
||||
const keyBodyReadError = "key_body_read_error"
|
||||
|
||||
var ErrRequestBodyTooLarge = errors.New("request body too large")
|
||||
|
||||
@@ -57,6 +62,12 @@ func GetRequestBody(c *gin.Context) (io.Seeker, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// A failed read has consumed and closed the body. Later readers get the
|
||||
// same error instead of "http: invalid Read on closed Body".
|
||||
if readErr, failed := c.Get(keyBodyReadError); failed {
|
||||
return nil, readErr.(error)
|
||||
}
|
||||
|
||||
maxMB := constant.MaxRequestBodyMB
|
||||
if maxMB <= 0 {
|
||||
maxMB = 128 // 默认 128MB
|
||||
@@ -71,8 +82,9 @@ func GetRequestBody(c *gin.Context) (io.Seeker, error) {
|
||||
|
||||
if err != nil {
|
||||
if IsRequestBodyTooLargeError(err) {
|
||||
return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
|
||||
err = errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
|
||||
}
|
||||
c.Set(keyBodyReadError, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -166,6 +178,62 @@ func GetContextKeyString(c *gin.Context, key constant.ContextKey) string {
|
||||
return c.GetString(string(key))
|
||||
}
|
||||
|
||||
// presetUpstreamHosts are the hosts, with any port, of the default base URLs
|
||||
// new-api presets for channel types. They are public endpoints, so errors keep
|
||||
// them readable. The port keeps a configured localhost upstream apart from the
|
||||
// Ollama preset.
|
||||
var presetUpstreamHosts = sync.OnceValue(func() map[string]struct{} {
|
||||
hosts := make(map[string]struct{}, len(constant.ChannelBaseURLs))
|
||||
for _, baseURL := range constant.ChannelBaseURLs {
|
||||
if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" {
|
||||
hosts[strings.ToLower(parsed.Host)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
})
|
||||
|
||||
// isPresetUpstreamHost reports whether address points at one of the preset
|
||||
// default base URL hosts.
|
||||
func isPresetUpstreamHost(address string) bool {
|
||||
rawURL := strings.TrimSpace(address)
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = "//" + rawURL
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, preset := presetUpstreamHosts()[strings.ToLower(parsed.Host)]
|
||||
return preset
|
||||
}
|
||||
|
||||
// UpstreamAddresses returns the addresses masked in errors shown to users: the
|
||||
// upstream the admin configured for the selected channel and the proxies its
|
||||
// requests may go through. Preset default base URLs are public and stay
|
||||
// readable. The origin actually requested only counts for a configured base
|
||||
// URL or an Advanced Custom channel, whose routes may target absolute URLs;
|
||||
// other adaptors build official endpoints themselves.
|
||||
func UpstreamAddresses(c *gin.Context) []string {
|
||||
// A channel without its own proxy dials through the environment proxy.
|
||||
environmentProxy := httpproxy.FromEnvironment()
|
||||
candidates := []string{environmentProxy.HTTPProxy, environmentProxy.HTTPSProxy}
|
||||
if setting, ok := GetContextKeyType[dto.ChannelSettings](c, constant.ContextKeyChannelSetting); ok {
|
||||
candidates = append(candidates, setting.Proxy)
|
||||
}
|
||||
baseURL := strings.TrimSpace(GetContextKeyString(c, constant.ContextKeyChannelBaseUrl))
|
||||
candidates = append(candidates, baseURL)
|
||||
if GetContextKeyInt(c, constant.ContextKeyChannelType) == constant.ChannelTypeAdvancedCustom || (baseURL != "" && !isPresetUpstreamHost(baseURL)) {
|
||||
candidates = append(candidates, GetContextKeyString(c, constant.ContextKeyUpstreamRequestOrigin))
|
||||
}
|
||||
addresses := make([]string, 0, len(candidates))
|
||||
for _, address := range candidates {
|
||||
if strings.TrimSpace(address) != "" && !isPresetUpstreamHost(address) {
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int {
|
||||
return c.GetInt(string(key))
|
||||
}
|
||||
@@ -196,11 +264,45 @@ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool)
|
||||
return t, false
|
||||
}
|
||||
|
||||
// ApiError responds with err's text. When err is or wraps a *Message, the
|
||||
// response also carries message_key and message_params for the web console.
|
||||
func ApiError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
ApiErrorStatus(c, http.StatusOK, err)
|
||||
}
|
||||
|
||||
// ApiErrorStatus is ApiError with another HTTP status. fields adds response
|
||||
// fields such as a stable error code or data.
|
||||
func ApiErrorStatus(c *gin.Context, status int, err error, fields ...gin.H) {
|
||||
body := gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
var message *Message
|
||||
if errors.As(err, &message) {
|
||||
maps.Copy(body, message.Fields())
|
||||
}
|
||||
for _, extra := range fields {
|
||||
maps.Copy(body, extra)
|
||||
}
|
||||
c.JSON(status, body)
|
||||
}
|
||||
|
||||
// ApiErrorT responds with a web console message; key is the English source
|
||||
// text, which the web console translates with params.
|
||||
func ApiErrorT(c *gin.Context, key string, params ...map[string]any) {
|
||||
ApiError(c, NewMessage(key, params...))
|
||||
}
|
||||
|
||||
// ApiSuccessT is the success counterpart of ApiErrorT.
|
||||
func ApiSuccessT(c *gin.Context, key string, data any, params ...map[string]any) {
|
||||
message := NewMessage(key, params...)
|
||||
body := gin.H{
|
||||
"success": true,
|
||||
"message": message.Error(),
|
||||
"data": data,
|
||||
}
|
||||
maps.Copy(body, message.Fields())
|
||||
c.JSON(http.StatusOK, body)
|
||||
}
|
||||
|
||||
func ApiErrorMsg(c *gin.Context, msg string) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
@@ -16,7 +15,7 @@ func init() {
|
||||
if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
|
||||
SafeSendBool(stopChan, true)
|
||||
}
|
||||
SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i))
|
||||
SysError(LogText("panic in gopool.RelayPool: %v", i))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,14 @@ func InitEnv() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
DefaultLanguage = os.Getenv("DEFAULT_LANGUAGE")
|
||||
SetLogLanguage(DefaultLanguage)
|
||||
|
||||
if os.Getenv("SESSION_SECRET") != "" {
|
||||
ss := os.Getenv("SESSION_SECRET")
|
||||
if ss == "random_string" {
|
||||
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||
log.Fatal("Please set SESSION_SECRET to a random string.")
|
||||
log.Println(LogText("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string."))
|
||||
log.Fatal(LogText("Please set SESSION_SECRET to a random string."))
|
||||
} else {
|
||||
SessionSecret = ss
|
||||
}
|
||||
@@ -95,7 +97,7 @@ func InitEnv() {
|
||||
if tr.TLSClientConfig != nil {
|
||||
tr.TLSClientConfig.InsecureSkipVerify = true
|
||||
} else {
|
||||
tr.TLSClientConfig = InsecureTLSConfig
|
||||
tr.TLSClientConfig = InsecureTLSConfig.Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +130,10 @@ func InitEnv() {
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
GlobalStaticRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_STATIC_RATE_LIMIT_ENABLE", false)
|
||||
GlobalStaticRateLimitNum = GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT", 1000)
|
||||
GlobalStaticRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
||||
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
||||
@@ -147,7 +153,7 @@ func initUserSessionSettings() {
|
||||
|
||||
const secondsPerDay = 24 * 60 * 60
|
||||
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
|
||||
SysError(fmt.Sprintf(
|
||||
SysError(LogText(
|
||||
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
|
||||
DefaultUserSessionRevokedRetentionDays,
|
||||
))
|
||||
@@ -157,7 +163,7 @@ func initUserSessionSettings() {
|
||||
if UserSessionIssuanceWindowSeconds > retentionSeconds {
|
||||
configuredWindow := UserSessionIssuanceWindowSeconds
|
||||
UserSessionIssuanceWindowSeconds = retentionSeconds
|
||||
SysError(fmt.Sprintf(
|
||||
SysError(LogText(
|
||||
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
||||
configuredWindow,
|
||||
retentionSeconds,
|
||||
@@ -169,7 +175,7 @@ func initUserSessionSettings() {
|
||||
func positiveUserSessionEnv(name string, fallback int) int {
|
||||
value := GetEnvOrDefault(name, fallback)
|
||||
if value <= 0 {
|
||||
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback))
|
||||
SysError(LogText("%s must be positive, using default value: %d", name, fallback))
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
type RawMessage = json.RawMessage
|
||||
|
||||
// hostJSONCodec is the single place where the host chooses its JSON engine.
|
||||
// Swap the implementation here (for example to sonic.ConfigStd) and every
|
||||
// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling,
|
||||
@@ -73,11 +75,11 @@ func IndentJson(data []byte) ([]byte, error) {
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func GetJsonType(data json.RawMessage) string {
|
||||
func GetJsonType(data RawMessage) string {
|
||||
return kitutil.GetJsonType(data)
|
||||
}
|
||||
|
||||
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
|
||||
func JsonRawMessageToString(data json.RawMessage) string {
|
||||
func JsonRawMessageToString(data RawMessage) string {
|
||||
return kitutil.JsonRawMessageToString(data)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func New(ctx context.Context, r *redis.Client) *RedisLimiter {
|
||||
// 预加载脚本
|
||||
limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err))
|
||||
common.SysLog(common.LogText("Failed to load rate limit script: %v", err))
|
||||
}
|
||||
instance = &RedisLimiter{
|
||||
client: r,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server log lines are English. logTextZhCN holds the Chinese text of the
|
||||
// lines that have one, keyed by their English format.
|
||||
//
|
||||
//go:embed log_text.zh-CN.json
|
||||
var logTextZhCN []byte
|
||||
|
||||
// logTexts is the translation table DEFAULT_LANGUAGE selected; nil prints English.
|
||||
var logTexts map[string]string
|
||||
|
||||
// SetLogLanguage selects the language of server log lines. A language that
|
||||
// starts with "zh" prints the Chinese text; anything else prints English.
|
||||
func SetLogLanguage(language string) {
|
||||
logTexts = nil
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(language)), "zh") {
|
||||
return
|
||||
}
|
||||
_ = Unmarshal(logTextZhCN, &logTexts)
|
||||
}
|
||||
|
||||
// LogText formats a server log line in the language of DEFAULT_LANGUAGE. format is
|
||||
// the English text and the key of its translation in log_text.zh-CN.json.
|
||||
func LogText(format string, args ...any) string {
|
||||
if translated, ok := logTexts[format]; ok {
|
||||
return fmt.Sprintf(translated, args...)
|
||||
}
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"%d quota points": "%d 点额度",
|
||||
"%s after refresh: %s": "%s(刷新凭证后重试): %s",
|
||||
"%s failed: %s": "%s 构建失败,%s",
|
||||
"%s must be positive, using default value: %d": "%s 必须大于 0,已使用默认值: %d",
|
||||
"%s must be positive; using default": "%s 必须大于 0,已使用默认值",
|
||||
"%s%.6f quota": "%s%.6f 额度",
|
||||
"--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)": "--form 'file=@\"%s\"' (大小: %d 字节, content-type: %s)",
|
||||
"Add abilities for channel %d failed: %s": "修复渠道一致性: 为渠道 %d 重建 abilities 记录失败: %s",
|
||||
"Auto selected group: %s": "Auto 分组选定: %s",
|
||||
"Auto selecting group: %s, priorityRetry: %d": "Auto 分组尝试: %s, priorityRetry: %d",
|
||||
"Batch task %s already transitioned by another process, skip billing": "批量轮询: 任务 %s 的状态已由其他进程更新,跳过计费",
|
||||
"Batch task %s reached terminal status without data; preserving existing task data": "批量轮询: 任务 %s 已结束,但上游没有返回数据,保留已有的任务数据",
|
||||
"Batch task response ignored: unknown task_id=%s": "批量轮询: 上游返回了未知的 task_id=%s,已忽略",
|
||||
"CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新后: id=%d, name=%s, status=%d, polling_index=%d",
|
||||
"CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新前: id=%d, name=%s, status=%d, polling_index=%d",
|
||||
"Channel #%d failed to update video async tasks: %s": "渠道 #%d 更新视频异步任务失败: %s",
|
||||
"Channel #%d pending video tasks: %d": "渠道 #%d 有 %d 个视频任务未完成",
|
||||
"CheckUserExistOrDeleted error: %v": "CheckUserExistOrDeleted 出错: %v",
|
||||
"Client IP %s passed the token IP restrictions check": "客户端 IP %s 在令牌的 IP 白名单内",
|
||||
"Creem API response received trade_no=%s status_code=%d body=%q": "Creem API 响应已收到 trade_no=%s status_code=%d body=%q",
|
||||
"Creem callback customer email is empty trade_no=%s creem_order_id=%s": "Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s",
|
||||
"Creem callback customer name is empty trade_no=%s creem_order_id=%s": "Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s",
|
||||
"Creem failed to create payment link user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to create subscription payment link trade_no=%s product_id=%s error=%q": "Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to create top-up order user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q",
|
||||
"Creem failed to parse product config user_id=%d error=%q": "Creem 产品配置解析失败 user_id=%d error=%q",
|
||||
"Creem failed to read payment request error=%q": "Creem 支付请求读取失败 error=%q",
|
||||
"Creem failed to read subscription payment request error=%q": "Creem 订阅支付请求读取失败 error=%q",
|
||||
"Creem order not paid, ignored request_id=%s order_id=%s order_status=%s": "Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s",
|
||||
"Creem order type not supported, ignored request_id=%s creem_order_id=%s order_type=%s": "Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s",
|
||||
"Creem payment completed callback trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q": "Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q",
|
||||
"Creem payment link created trade_no=%s response_id=%s checkout_url=%q": "Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q",
|
||||
"Creem payment request received user_id=%d body=%q": "Creem 支付请求已收到 user_id=%d body=%q",
|
||||
"Creem payment request sent api_url=%s product_id=%s email=%q trade_no=%s": "Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s",
|
||||
"Creem subscription order processed trade_no=%s creem_order_id=%s": "Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s",
|
||||
"Creem subscription order processing failed trade_no=%s creem_order_id=%s error=%q": "Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q",
|
||||
"Creem top-up order created user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f": "Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f",
|
||||
"Creem top-up order not found trade_no=%s creem_order_id=%s": "Creem 充值订单不存在 trade_no=%s creem_order_id=%s",
|
||||
"Creem top-up order not pending, ignored trade_no=%s status=%s creem_order_id=%s": "Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s",
|
||||
"Creem top-up processing failed trade_no=%s creem_order_id=%s client_ip=%s error=%q": "Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q",
|
||||
"Creem top-up succeeded trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s": "Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s",
|
||||
"Creem using test environment api_url=%s": "Creem 使用测试环境 api_url=%s",
|
||||
"Creem webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
|
||||
"Creem webhook failed to read request body path=%q client_ip=%s error=%q": "Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Creem webhook ignored event event_type=%s event_id=%s": "Creem webhook 忽略事件 event_type=%s event_id=%s",
|
||||
"Creem webhook missing request_id event_id=%s order_id=%s": "Creem webhook 缺少 request_id event_id=%s order_id=%s",
|
||||
"Creem webhook missing signature path=%q client_ip=%s body=%q": "Creem webhook 缺少签名 path=%q client_ip=%s body=%q",
|
||||
"Creem webhook parsed event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s": "Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s",
|
||||
"Creem webhook received request path=%q client_ip=%s signature=%q body=%q": "Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Creem webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Creem webhook secret is not configured test_mode=%t signature=%q body=%q": "Creem webhook secret 未配置 test_mode=%t signature=%q body=%q",
|
||||
"Creem webhook signature check skipped reason=test_mode signature=%q body=%q": "Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q",
|
||||
"Creem webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Creem webhook signature verified path=%q client_ip=%s": "Creem webhook 验签成功 path=%q client_ip=%s",
|
||||
"Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry": "当前分组 %s 重试次数已用完 (priorityRetry=%d >= RetryTimes=%d),下次重试将切换到下一个分组",
|
||||
"Database pinged successfully": "数据库连接正常",
|
||||
"Delete abilities failed: %s": "修复渠道一致性: 删除 abilities 记录失败: %s",
|
||||
"Do gemini request failed: %s": "请求 Gemini 失败: %s",
|
||||
"Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q": "易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q",
|
||||
"Epay client is not initialized path=%q client_ip=%s": "易支付 client 未初始化 path=%q client_ip=%s",
|
||||
"Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s": "易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
|
||||
"Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
|
||||
"Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s": "易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay order status invalid trade_no=%s callback_type=%s client_ip=%s": "易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay top-up order created user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q": "易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q",
|
||||
"Epay top-up processing failed trade_no=%s client_ip=%s error=%q": "易支付 充值处理失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s": "易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s",
|
||||
"Epay top-up succeeded trade_no=%s user_id=%d quota_to_add=%d money=%.2f": "易支付充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f",
|
||||
"Epay webhook failed to parse POST form path=%q client_ip=%s error=%q": "易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q",
|
||||
"Epay webhook failed to write response path=%q client_ip=%s error=%q": "易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q",
|
||||
"Epay webhook failed to write response trade_no=%s client_ip=%s error=%q": "易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Epay webhook ignored event trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
|
||||
"Epay webhook params are empty path=%q client_ip=%s": "易支付 webhook 参数为空 path=%q client_ip=%s",
|
||||
"Epay webhook received request path=%q client_ip=%s method=%s params=%q": "易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q",
|
||||
"Epay webhook rejected reason=webhook_disabled path=%q client_ip=%s": "易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q": "易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q",
|
||||
"Epay webhook signature verification failed path=%q client_ip=%s verify_status=false": "易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false",
|
||||
"Epay webhook signature verified trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
|
||||
"Error getting sql.DB from GORM: %v": "从 GORM 取得 sql.DB 失败: %v",
|
||||
"Error pinging DB: %v": "数据库 ping 失败: %v",
|
||||
"Error: Failed to decode base64 string": "base64 字符串解码失败",
|
||||
"FRONTEND_BASE_URL is ignored on master node": "当前是主节点,FRONTEND_BASE_URL 设置已忽略",
|
||||
"Failed to get binding count for provider %s: %s": "查询 OAuth 提供商 %s 的绑定用户数失败: %s",
|
||||
"Failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
|
||||
"Failed to load rate limit script: %v": "加载限流脚本失败: %v",
|
||||
"Failed to project plugin video for task %s": "读取任务 %s 的制品列表失败",
|
||||
"Failed to query task %s: %s": "查询任务 %s 失败: %s",
|
||||
"Failed to resolve plugin video content for task %s: %v": "任务 %s: 生成视频内容的下载请求失败: %v",
|
||||
"Failed to stream image: %v": "向客户端转发图片失败: %v",
|
||||
"Failed to stream task media: %v": "向客户端转发任务媒体文件失败: %v",
|
||||
"Failed to update task %s: %s": "更新任务 %s 失败: %s",
|
||||
"Failed to update video task %s: %s": "更新视频任务 %s 失败: %s",
|
||||
"Failed to write to disk cache, falling back to memory: %v": "写入磁盘缓存失败,改用内存缓存: %v",
|
||||
"File cached to disk: %s, size: %d bytes": "文件已缓存到磁盘: %s,大小: %d 字节",
|
||||
"Fix null mj_id task error: %v": "修复 mj_id 为空的任务出错: %v",
|
||||
"Fix null mj_id task success: %v": "修复 mj_id 为空的任务成功: %v",
|
||||
"Fix null task_id task error: %v": "修复 task_id 为空的任务出错: %v",
|
||||
"Fix null task_id task success: %v": "修复 task_id 为空的任务成功: %v",
|
||||
"Gemini embedding request body: %s": "Gemini 嵌入请求体: %s",
|
||||
"Gemini native embedding response body: %s": "Gemini 原生格式嵌入响应体: %s",
|
||||
"Gemini native response body: %s": "Gemini 原生格式响应体: %s",
|
||||
"Gemini request body: %s": "Gemini 请求体: %s",
|
||||
"Gemini response body: %s": "Gemini 响应体: %s",
|
||||
"Gemini responses response body: %s": "Gemini Responses 响应体: %s",
|
||||
"Gemini stream ended unexpectedly: %s": "Gemini 流式响应异常中断: %s",
|
||||
"Get Mjp Task parse body error2: %v, body: %s": "解析 Midjourney 任务查询响应失败: %v,响应体: %s",
|
||||
"Get Mjp Task parse body error: %v": "读取 Midjourney 任务查询响应失败: %v",
|
||||
"Get Suno Task parse body error: %v": "读取 Suno 任务查询响应失败: %v",
|
||||
"Get Task Do req error: %v": "发送任务查询请求失败: %v",
|
||||
"Get Task error: %v": "创建任务查询请求失败: %v",
|
||||
"Get Task marshal body error: %v": "序列化任务查询请求体失败: %v",
|
||||
"Get Task status code: %d": "任务查询返回状态码 %d",
|
||||
"GetAllEnableAbilityWithChannels error: %v": "GetAllEnableAbilityWithChannels 出错: %v",
|
||||
"GetPasskeyByCredentialID: database error for credential ID: %v": "GetPasskeyByCredentialID: 按凭证 ID 查询数据库出错: %v",
|
||||
"GetPasskeyByCredentialID: empty credential ID": "GetPasskeyByCredentialID: 凭证 ID 为空",
|
||||
"GetPasskeyByCredentialID: passkey not found for credential ID length %d": "GetPasskeyByCredentialID: 未找到凭证 ID 对应的通行密钥,凭证 ID 长度 %d",
|
||||
"GetPasskeyByUserID: database error for user %d: %v": "GetPasskeyByUserID: 查询用户 %d 时数据库出错: %v",
|
||||
"GetPasskeyByUserID: empty user ID": "GetPasskeyByUserID: 用户 ID 为空",
|
||||
"GetPreferredModelOwnerChannelTypes error: %v": "GetPreferredModelOwnerChannelTypes 出错: %v",
|
||||
"InitChannelCache failed: %s": "InitChannelCache 失败: %s",
|
||||
"InitChannelCache panic: %v": "InitChannelCache 发生 panic: %v",
|
||||
"InitChannelCache panic: %v, retrying once": "InitChannelCache 发生 panic: %v,重试一次",
|
||||
"LoadFileSource starting for: %s": "LoadFileSource 开始加载: %s",
|
||||
"Loaded %d custom OAuth providers": "已加载 %d 个自定义 OAuth 提供商",
|
||||
"Loaded custom OAuth provider: %s (%s)": "已加载自定义 OAuth 提供商: %s (%s)",
|
||||
"Local:": "本机地址:",
|
||||
"Login database error for user %s: %v": "用户 %s 登录时数据库出错: %v",
|
||||
"Midjourney action uses origin channel: id=%s, base_url=%s": "Midjourney 后续操作使用原任务所在的渠道: id=%s, base_url=%s",
|
||||
"Midjourney refund succeeded but clearing the quota failed, task %s: %s": "Midjourney 退款成功但清除 quota 失败 task %s: %s",
|
||||
"Midjourney task response ignored: unknown mj_id=%s": "上游返回了未知的 Midjourney 任务 mj_id=%s,已忽略",
|
||||
"Network:": "网络地址:",
|
||||
"New API %s started": "New API %s 已启动",
|
||||
"No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.": "未找到 .env 文件,直接使用进程的环境变量。需要时可以新建 .env 文件,把变量写在里面。",
|
||||
"No available channel in group %s for model %s at priorityRetry %d, trying next group": "分组 %s 没有模型 %s 的可用渠道 (priorityRetry=%d),尝试下一个分组",
|
||||
"No update needed for task %s": "任务 %s 没有变化,跳过更新",
|
||||
"OpenRouter parse failed from %s: %s": "解析 %s 返回的 OpenRouter 数据失败: %s",
|
||||
"PasskeyLogin: userHandle parse error for credential, length: %d": "PasskeyLogin: 凭证的 userHandle 无法解析为用户 ID,长度: %d",
|
||||
"Please set SESSION_SECRET to a random string.": "请将 SESSION_SECRET 设置为随机字符串。",
|
||||
"REDIS_CONN_STRING not set, Redis is not enabled": "未设置 REDIS_CONN_STRING,Redis 未启用",
|
||||
"Redis connected to %s": "Redis 已连接到 %s",
|
||||
"Redis database: %d": "Redis 数据库: %d",
|
||||
"Redis is enabled": "Redis 已启用",
|
||||
"Redis ping test failed: %s": "Redis 连接测试失败: %s",
|
||||
"RemoveDisabledFields Marshal error :%s": "RemoveDisabledFields 序列化失败 :%s",
|
||||
"RemoveDisabledFields Unmarshal error :%s": "RemoveDisabledFields 反序列化失败 :%s",
|
||||
"RemoveGeminiDisabledFields Marshal error: %s": "RemoveGeminiDisabledFields 序列化失败: %s",
|
||||
"RemoveGeminiDisabledFields Unmarshal error: %s": "RemoveGeminiDisabledFields 反序列化失败: %s",
|
||||
"Rerank request body: %s": "重排序请求体: %s",
|
||||
"SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check": "已设置 SKIP_64BIT_QUOTA_SCHEMA_CHECK=true,跳过用户额度字段的类型检查",
|
||||
"SQL_DSN not set, using SQLite as database": "未设置 SQL_DSN,数据库使用 SQLite",
|
||||
"SSE ping data sent": "SSE ping 数据已发送",
|
||||
"SSE ping error, stopping goroutine: %s": "SSE ping 出错,停止 goroutine: %s",
|
||||
"SSE ping error: %s": "SSE ping 出错: %s",
|
||||
"SSE ping goroutine panic recovered: %v": "SSE ping goroutine 发生 panic,已恢复: %v",
|
||||
"SSE ping goroutine started": "SSE ping goroutine 已启动",
|
||||
"SSE ping goroutine stopped": "SSE ping goroutine 已停止",
|
||||
"SSE ping goroutine stopped by defer": "SSE ping goroutine 已由 defer 停止",
|
||||
"SSE ping goroutine timeout, stopping": "SSE ping goroutine 超时,正在停止",
|
||||
"SSE ping ticker stopped": "SSE ping ticker 已停止",
|
||||
"SYNC_FREQUENCY not set, use default value 60": "未设置 SYNC_FREQUENCY,使用默认值 60",
|
||||
"SetPayloadHash body: %s": "SetPayloadHash 请求体: %s",
|
||||
"Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s": "Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s",
|
||||
"Stripe async payment failed but local order not found trade_no=%s client_ip=%s": "Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s",
|
||||
"Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s": "Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s",
|
||||
"Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s": "Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s",
|
||||
"Stripe async payment failed event missing order number client_ip=%s": "Stripe 异步支付失败事件缺少订单号 client_ip=%s",
|
||||
"Stripe async payment failed trade_no=%s client_ip=%s": "Stripe 异步支付失败 trade_no=%s client_ip=%s",
|
||||
"Stripe async payment succeeded trade_no=%s client_ip=%s": "Stripe 异步支付成功 trade_no=%s client_ip=%s",
|
||||
"Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s": "Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s",
|
||||
"Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s": "Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s",
|
||||
"Stripe checkout.expired missing order number": "Stripe checkout.expired 缺少订单号",
|
||||
"Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Stripe failed to create subscription payment link trade_no=%s plan_id=%d error=%q": "Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q",
|
||||
"Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q": "Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Stripe order completion missing order number client_ip=%s": "Stripe 完成订单时缺少订单号 client_ip=%s",
|
||||
"Stripe subscription order expired trade_no=%s": "Stripe 订阅订单已过期 trade_no=%s",
|
||||
"Stripe subscription order expiry processing failed trade_no=%s error=%q": "Stripe 订阅订单过期处理失败 trade_no=%s error=%q",
|
||||
"Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s": "Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s",
|
||||
"Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
|
||||
"Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f": "Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f",
|
||||
"Stripe top-up order expired trade_no=%s": "Stripe 充值订单已过期 trade_no=%s",
|
||||
"Stripe top-up order expiry processing failed trade_no=%s error=%q": "Stripe 充值订单过期处理失败 trade_no=%s error=%q",
|
||||
"Stripe top-up order marked as failed trade_no=%s client_ip=%s": "Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s",
|
||||
"Stripe top-up order not found, cannot mark as expired trade_no=%s": "Stripe 充值订单不存在,无法标记过期 trade_no=%s",
|
||||
"Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
|
||||
"Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s": "Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s",
|
||||
"Stripe webhook failed to read request body path=%q client_ip=%s error=%q": "Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Stripe webhook ignored event event_type=%s client_ip=%s": "Stripe webhook 忽略事件 event_type=%s client_ip=%s",
|
||||
"Stripe webhook received request path=%q client_ip=%s signature=%q body=%q": "Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Stripe webhook signature verification failed path=%q client_ip=%s error=%q": "Stripe webhook 验签失败 path=%q client_ip=%s error=%q",
|
||||
"Stripe webhook signature verified event_type=%s client_ip=%s path=%q": "Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q",
|
||||
"Successfully migrated %s.%s to decimal(10,6)": "字段 %s.%s 的类型已改为 decimal(10,6)",
|
||||
"Successfully migrated %s.%s to text": "字段 %s.%s 的类型已改为 text",
|
||||
"Task %s CAS lost or no-op update, skip billing": "任务 %s 的状态已由其他进程更新或没有变化,跳过计费",
|
||||
"Task %s failed": "任务 %s 失败",
|
||||
"Task %s failed: %s": "任务 %s 失败: %s",
|
||||
"Task %s not found in taskM": "任务 %s 不在本次轮询的任务列表中",
|
||||
"Token has IP restrictions, checking client IP %s": "令牌设置了 IP 白名单,检查客户端 IP %s",
|
||||
"TokenAuth GetUserCache error for user %d: %v": "TokenAuth GetUserCache 获取用户 %d 出错: %v",
|
||||
"TokenAuth ValidateUserToken database error: %s": "TokenAuth ValidateUserToken 数据库出错: %s",
|
||||
"TokenAuthReadOnly GetTokenByKey database error: %s": "TokenAuthReadOnly GetTokenByKey 数据库出错: %s",
|
||||
"TokenAuthReadOnly GetUserCache error for user %d: %v": "TokenAuthReadOnly GetUserCache 获取用户 %d 出错: %v",
|
||||
"Truncate abilities failed: %s": "修复渠道一致性: 清空 abilities 表失败: %s",
|
||||
"Trying to read %d bytes to determine file type": "读取前 %d 字节来判断文件类型",
|
||||
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d": "USER_SESSION_ISSUANCE_WINDOW_SECONDS 大于已吊销会话的保留时长,已按保留时长生效: configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
||||
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d": "USER_SESSION_REVOKED_RETENTION_DAYS 的值过大,已使用默认值: %d",
|
||||
"UpdateBatchTasks fail: %s": "UpdateBatchTasks 失败: %s",
|
||||
"UpdateMidjourneyTask error: %v": "UpdateMidjourneyTask 出错: %v",
|
||||
"UpdateMidjourneyTask task error: %s": "UpdateMidjourneyTask 更新任务出错: %s",
|
||||
"UpdateSunoTask error: %v": "UpdateSunoTask 出错: %v",
|
||||
"UpdateSunoTask task error: %s": "UpdateSunoTask 更新任务出错: %s",
|
||||
"UpdateVideoTask error: %v": "UpdateVideoTask 出错: %v",
|
||||
"UpdateVideoTasks fail: %s": "UpdateVideoTasks 失败: %s",
|
||||
"UpdateWithStatus failed for task %s: %s": "更新任务 %s 的状态失败 (UpdateWithStatus): %s",
|
||||
"UpsertPasskeyCredential: failed to create credential for user %d: %v": "UpsertPasskeyCredential: 为用户 %d 创建凭证失败: %v",
|
||||
"UpsertPasskeyCredential: failed to delete existing credential for user %d: %v": "UpsertPasskeyCredential: 删除用户 %d 的现有凭证失败: %v",
|
||||
"ValidateUserToken: failed to get token: %s": "ValidateUserToken: 获取令牌失败: %s",
|
||||
"WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.": "警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。",
|
||||
"WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.": "警告:TRUSTED_PROXIES 未设置或为空。为兼容已有部署,来自回环地址、RFC 1918 内网地址和 IPv6 ULA 地址的代理会被信任。设置 TRUSTED_PROXIES=none 表示任何代理都不信任,也可以填写代理的 IP 或 CIDR 来替换这组默认值。",
|
||||
"Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q": "Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q": "Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
|
||||
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q": "Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
|
||||
"Waffo Pancake failed to create subscription checkout session user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create subscription order user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
|
||||
"Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Waffo Pancake failed to fetch store and product catalog error=%q": "Waffo Pancake 拉取店铺与产品目录失败 error=%q",
|
||||
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q": "Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q",
|
||||
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q": "Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
|
||||
"Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
|
||||
"Waffo Pancake subscription order created user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f": "Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f",
|
||||
"Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f": "Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f",
|
||||
"Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
|
||||
"Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s": "Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
|
||||
"Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q": "Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q",
|
||||
"Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s",
|
||||
"Waffo SDK initialization failed user_id=%d trade_no=%s error=%q": "Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo failed to create order user_id=%d trade_no=%s error=%q": "Waffo 创建订单失败 user_id=%d trade_no=%s error=%q",
|
||||
"Waffo failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
|
||||
"Waffo failed to mark order as failed trade_no=%s error=%q": "Waffo 标记失败订单状态失败 trade_no=%s error=%q",
|
||||
"Waffo failed to parse payment callback payload event_type=%s client_ip=%s error=%q body=%q": "Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q",
|
||||
"Waffo invalid payment method index user_id=%d pay_method_index=%d method_count=%d": "Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d",
|
||||
"Waffo invalid payment method user_id=%d pay_method_type=%s pay_method_name=%q": "Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q",
|
||||
"Waffo order creation rejected user_id=%d trade_no=%s code=%s message=%q response=%q": "Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q",
|
||||
"Waffo order not successful, top-up skipped trade_no=%s order_status=%s client_ip=%s": "Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s",
|
||||
"Waffo top-up order created user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q": "Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q",
|
||||
"Waffo top-up processing failed trade_no=%s client_ip=%s error=%q": "Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q",
|
||||
"Waffo top-up succeeded trade_no=%s client_ip=%s": "Waffo 充值成功 trade_no=%s client_ip=%s",
|
||||
"Waffo webhook SDK initialization failed path=%q client_ip=%s error=%q": "Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
|
||||
"Waffo webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q",
|
||||
"Waffo webhook ignored event event_type=%s client_ip=%s": "Waffo webhook 忽略事件 event_type=%s client_ip=%s",
|
||||
"Waffo webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
|
||||
"Waffo webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
|
||||
"Waffo webhook verified and parsed event_type=%s merchant_order_id=%s order_status=%s client_ip=%s": "Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s",
|
||||
"Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.": "警告:SESSION_COOKIE_SECURE 未开启。登录 Cookie 可经 HTTP 明文传输,续期登录和退出登录接口接受任意来源(Origin)的请求。生产环境请设置 SESSION_COOKIE_SECURE=true。",
|
||||
"Warning: failed to migrate %s.%s to decimal: %v": "警告:把字段 %s.%s 的类型改为 decimal 失败: %v",
|
||||
"Warning: failed to query metadata for %s.%s: %v": "警告:查询字段 %s.%s 的类型信息失败: %v",
|
||||
"[OAuth-Discord] ExchangeToken decode error: %s": "[OAuth-Discord] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-Discord] ExchangeToken error: %s": "[OAuth-Discord] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-Discord] ExchangeToken failed: empty access token": "[OAuth-Discord] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-Discord] ExchangeToken response status: %d": "[OAuth-Discord] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-Discord] ExchangeToken success: scope=%s": "[OAuth-Discord] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-Discord] GetUserInfo decode error: %s": "[OAuth-Discord] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-Discord] GetUserInfo error: %s": "[OAuth-Discord] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-Discord] GetUserInfo failed: empty user fields": "[OAuth-Discord] GetUserInfo 失败: 返回的用户信息为空",
|
||||
"[OAuth-Discord] GetUserInfo failed: status=%d": "[OAuth-Discord] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-Discord] GetUserInfo response status: %d": "[OAuth-Discord] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-Discord] GetUserInfo success: uid=%s, username=%s, name=%s": "[OAuth-Discord] GetUserInfo 成功: uid=%s, username=%s, name=%s",
|
||||
"[OAuth-Discord] GetUserInfo: fetching user info": "[OAuth-Discord] GetUserInfo: 开始获取用户信息",
|
||||
"[OAuth-Generic-%s] ExchangeToken OAuth error: %s - %s": "[OAuth-Generic-%s] ExchangeToken 提供商返回错误: %s - %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken error: %s": "[OAuth-Generic-%s] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken failed: empty access token": "[OAuth-Generic-%s] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-Generic-%s] ExchangeToken parse error: %s": "[OAuth-Generic-%s] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken read body error: %s": "[OAuth-Generic-%s] ExchangeToken 读取响应体失败: %s",
|
||||
"[OAuth-Generic-%s] ExchangeToken response status: %d": "[OAuth-Generic-%s] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-Generic-%s] ExchangeToken success: scope=%s": "[OAuth-Generic-%s] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-Generic-%s] GetUserInfo error: %s": "[OAuth-Generic-%s] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-Generic-%s] GetUserInfo failed: empty user ID (field: %s)": "[OAuth-Generic-%s] GetUserInfo 失败: 用户 ID 为空 (字段: %s)",
|
||||
"[OAuth-Generic-%s] GetUserInfo failed: status=%d": "[OAuth-Generic-%s] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-Generic-%s] GetUserInfo read body error: %s": "[OAuth-Generic-%s] GetUserInfo 读取响应体失败: %s",
|
||||
"[OAuth-Generic-%s] GetUserInfo response status: %d": "[OAuth-Generic-%s] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s": "[OAuth-Generic-%s] GetUserInfo 成功: id=%s, username=%s, name=%s, email=%s",
|
||||
"[OAuth-Generic-%s] GetUserInfo: fetching user info from %s": "[OAuth-Generic-%s] GetUserInfo: 开始从 %s 获取用户信息",
|
||||
"[OAuth-Generic-%s] access denied by policy: field=%s op=%s expected=%v current=%v": "[OAuth-Generic-%s] 不满足访问策略,已拒绝登录: field=%s op=%s expected=%v current=%v",
|
||||
"[OAuth-Generic-%s] invalid access policy: %s": "[OAuth-Generic-%s] 访问策略配置无效: %s",
|
||||
"[OAuth-GitHub] ExchangeToken decode error: %s": "[OAuth-GitHub] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-GitHub] ExchangeToken error: %s": "[OAuth-GitHub] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-GitHub] ExchangeToken failed: empty access token": "[OAuth-GitHub] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-GitHub] ExchangeToken response status: %d": "[OAuth-GitHub] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-GitHub] ExchangeToken success: scope=%s": "[OAuth-GitHub] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-GitHub] GetUserInfo decode error: %s": "[OAuth-GitHub] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-GitHub] GetUserInfo error: %s": "[OAuth-GitHub] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-GitHub] GetUserInfo failed: empty id or login field": "[OAuth-GitHub] GetUserInfo 失败: id 或 login 字段为空",
|
||||
"[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s": "[OAuth-GitHub] GetUserInfo 失败: status=%d, body=%s",
|
||||
"[OAuth-GitHub] GetUserInfo response status: %d": "[OAuth-GitHub] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-GitHub] GetUserInfo success: id=%d, login=%s, name=%s, email=%s": "[OAuth-GitHub] GetUserInfo 成功: id=%d, login=%s, name=%s, email=%s",
|
||||
"[OAuth-GitHub] GetUserInfo: fetching user info": "[OAuth-GitHub] GetUserInfo: 开始获取用户信息",
|
||||
"[OAuth-GitHub] GetVerifiedEmails decode error: %s": "[OAuth-GitHub] GetVerifiedEmails 解析响应失败: %s",
|
||||
"[OAuth-GitHub] GetVerifiedEmails error: %s": "[OAuth-GitHub] GetVerifiedEmails 请求失败: %s",
|
||||
"[OAuth-GitHub] GetVerifiedEmails failed: status=%d": "[OAuth-GitHub] GetVerifiedEmails 失败: status=%d",
|
||||
"[OAuth-GitHub] GetVerifiedEmails success: verified=%d": "[OAuth-GitHub] GetVerifiedEmails 成功: verified=%d",
|
||||
"[OAuth-LinuxDO] ExchangeToken decode error: %s": "[OAuth-LinuxDO] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken error: %s": "[OAuth-LinuxDO] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken failed: %s": "[OAuth-LinuxDO] ExchangeToken 失败: %s",
|
||||
"[OAuth-LinuxDO] ExchangeToken response status: %d": "[OAuth-LinuxDO] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-LinuxDO] ExchangeToken success": "[OAuth-LinuxDO] ExchangeToken 成功",
|
||||
"[OAuth-LinuxDO] GetUserInfo decode error: %s": "[OAuth-LinuxDO] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-LinuxDO] GetUserInfo error: %s": "[OAuth-LinuxDO] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-LinuxDO] GetUserInfo failed: invalid user id": "[OAuth-LinuxDO] GetUserInfo 失败: 用户 id 无效",
|
||||
"[OAuth-LinuxDO] GetUserInfo response status: %d": "[OAuth-LinuxDO] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-LinuxDO] GetUserInfo success: id=%d, username=%s": "[OAuth-LinuxDO] GetUserInfo 成功: id=%d, username=%s",
|
||||
"[OAuth-LinuxDO] GetUserInfo: trust level too low (required=%d, current=%d)": "[OAuth-LinuxDO] GetUserInfo: 信任级别不足 (required=%d, current=%d)",
|
||||
"[OAuth-OIDC] ExchangeToken decode error: %s": "[OAuth-OIDC] ExchangeToken 解析响应失败: %s",
|
||||
"[OAuth-OIDC] ExchangeToken error: %s": "[OAuth-OIDC] ExchangeToken 请求失败: %s",
|
||||
"[OAuth-OIDC] ExchangeToken failed: empty access token": "[OAuth-OIDC] ExchangeToken 失败: 响应里没有 access token",
|
||||
"[OAuth-OIDC] ExchangeToken response status: %d": "[OAuth-OIDC] ExchangeToken 响应状态码: %d",
|
||||
"[OAuth-OIDC] ExchangeToken success: scope=%s": "[OAuth-OIDC] ExchangeToken 成功: scope=%s",
|
||||
"[OAuth-OIDC] GetUserInfo decode error: %s": "[OAuth-OIDC] GetUserInfo 解析响应失败: %s",
|
||||
"[OAuth-OIDC] GetUserInfo error: %s": "[OAuth-OIDC] GetUserInfo 请求失败: %s",
|
||||
"[OAuth-OIDC] GetUserInfo failed: empty fields (sub=%s, email=%s)": "[OAuth-OIDC] GetUserInfo 失败: 字段为空 (sub=%s, email=%s)",
|
||||
"[OAuth-OIDC] GetUserInfo failed: status=%d": "[OAuth-OIDC] GetUserInfo 失败: status=%d",
|
||||
"[OAuth-OIDC] GetUserInfo response status: %d": "[OAuth-OIDC] GetUserInfo 响应状态码: %d",
|
||||
"[OAuth-OIDC] GetUserInfo success: sub=%s, username=%s, name=%s, email=%s": "[OAuth-OIDC] GetUserInfo 成功: sub=%s, username=%s, name=%s, email=%s",
|
||||
"[OAuth] Failed to load verified emails for user %d: %s": "[OAuth] 加载用户 %d 的已验证邮箱失败: %s",
|
||||
"access token last-use update failed (token_id=%d): %v": "更新访问令牌最后使用时间失败 (token_id=%d): %v",
|
||||
"access token route is not declared: %s": "这条路由没有声明访问令牌的访问规则,已拒绝: %s",
|
||||
"admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t": "管理员重置了订阅套餐 %d 的额度: reset_count=%d user_count=%d advance_reset_time=%t",
|
||||
"admin subscription creation": "管理员创建订阅",
|
||||
"admin subscription deletion": "管理员删除订阅",
|
||||
"admin subscription update": "管理员修改订阅",
|
||||
"api_version not found, using default: %s": "未找到 api_version,使用默认值: %s",
|
||||
"audit actor role unavailable (request_id=%s, actor_role=%d)": "无法识别审计日志的操作者角色 (request_id=%s, actor_role=%d)",
|
||||
"audit log write failed (request_id=%s): %v": "审计日志写入失败 (request_id=%s): %v",
|
||||
"audit log write failed (request_id=%s): log database unavailable": "审计日志写入失败 (request_id=%s): 日志数据库不可用",
|
||||
"auth session internal error (%s %s): %v": "登录会话接口内部错误 (%s %s): %v",
|
||||
"authorization policy %d has an unsupported legacy scope; loaded as deny; review the stored policy before granting access": "授权策略 %d 带有已不支持的旧范围设置,已按拒绝(deny)加载。放行前请先检查这条已保存的策略。",
|
||||
"bad response status code %d with empty error message, body: %s": "上游返回状态码 %d,错误信息为空,响应体: %s",
|
||||
"bad response status code %d, body: %s": "上游返回异常状态码 %d,响应体: %s",
|
||||
"batch update enabled with interval %ss": "已启用批量更新,每 %s 秒执行一次",
|
||||
"batch update finished": "批量更新完成",
|
||||
"batch update overflow: type=%d id=%d old=%d value=%d": "批量更新的累加值溢出: type=%d id=%d old=%d value=%d",
|
||||
"batch update started": "批量更新开始",
|
||||
"billable tool count ignored: tool=%s count=%d": "工具计费次数无效,已忽略: tool=%s count=%d",
|
||||
"build initial plugin router: %s": "初始化插件路由失败: %s",
|
||||
"build request failed: %s": "创建请求失败: %s",
|
||||
"build task plugin native submit view failed: %s": "任务插件提交响应: 构建任务数据失败: %s",
|
||||
"build task protocol relay info failed: %s": "任务协议: 构建请求上下文失败: %s",
|
||||
"build task protocol renderer context failed": "任务协议: 构建渲染钩子的上下文失败",
|
||||
"build task protocol view failed: %s": "任务协议: 构建任务数据失败: %s",
|
||||
"channel #%d failed to update async tasks: %s": "渠道 #%d 更新异步任务失败: %s",
|
||||
"channel #%d has %d unfinished tasks": "渠道 #%d 未完成的任务有: %d",
|
||||
"channel %d polling index: %d": "渠道 %d 的多密钥轮询位置: %d",
|
||||
"channel %q (#%d) failed, disabling it, reason: %s": "通道「%s」(#%d)发生错误,准备禁用,原因:%s",
|
||||
"channel %q (#%d) has automatic disabling turned off, skipping": "通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作",
|
||||
"channel affinity cache delete current failed: err=%v": "渠道亲和性缓存: 删除当前请求的记录失败: err=%v",
|
||||
"channel affinity cache delete many failed: err=%v": "渠道亲和性缓存: 批量删除失败: err=%v",
|
||||
"channel affinity cache get failed: key=%s, err=%v": "渠道亲和性缓存: 读取失败: key=%s, err=%v",
|
||||
"channel affinity cache list keys failed: err=%v": "渠道亲和性缓存: 列出缓存键失败: err=%v",
|
||||
"channel affinity cache set failed: key=%s, err=%v": "渠道亲和性缓存: 写入失败: key=%s, err=%v",
|
||||
"channel error (channel #%d, status code: %d): %s": "渠道请求出错 (渠道 #%d,状态码 %d): %s",
|
||||
"channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d": "请求被多处指定了渠道,已按优先级采用其中一个: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
|
||||
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v": "渠道测试响应异常: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
|
||||
"channels synced from database": "已从数据库同步渠道",
|
||||
"channels update done": "所有渠道的余额更新完成",
|
||||
"charging the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
"claude response usage is not complete, maybe upstream error": "Claude 响应里的用量信息不完整,可能是上游出错",
|
||||
"closed %d active websocket connection(s), channels=%v, kinds=%v, reason=%s": "已关闭 %d 个使用中的 WebSocket 连接: channels=%v, kinds=%v, reason=%s",
|
||||
"codex credential auto-refresh task started: tick=%s threshold=%s": "Codex 凭证自动刷新任务已启动: tick=%s threshold=%s",
|
||||
"codex credential auto-refresh: InitChannelCache panic: %v": "Codex 凭证自动刷新: InitChannelCache 发生 panic: %v",
|
||||
"codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v": "Codex 凭证自动刷新: channel_id=%d name=%s 刷新失败: %v",
|
||||
"codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s": "Codex 凭证自动刷新: channel_id=%d name=%s 已刷新,expires_at=%s",
|
||||
"codex credential auto-refresh: query channels failed: %v": "Codex 凭证自动刷新: 查询渠道失败: %v",
|
||||
"codex credential auto-refresh: scanned=%d refreshed=%d": "Codex 凭证自动刷新: scanned=%d refreshed=%d",
|
||||
"compile task plugin %s@%s: %v": "编译任务插件 %s@%s 失败: %v",
|
||||
"configure plugin router trusted proxies: %s": "为插件路由设置可信代理失败: %s",
|
||||
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q": "格式转换诊断: code=%q severity=%q from=%q to=%q path=%q message=%q",
|
||||
"conversion diagnostics truncated after %d distinct entries": "格式转换诊断已达 %d 条上限,后续不再记录",
|
||||
"converted embedding request body: %s": "转换后的嵌入请求体: %s",
|
||||
"cpu usage too high": "CPU 使用率过高",
|
||||
"creem topup": "Creem 充值",
|
||||
"creem topup failed: %s": "Creem 充值失败: %s",
|
||||
"dashboard authentication error: %s": "控制台登录校验出错: %s",
|
||||
"data handler goroutine panic: %v": "数据处理 goroutine 发生 panic: %v",
|
||||
"database migration started": "开始数据库迁移",
|
||||
"do request failed: %s": "请求上游失败: %s",
|
||||
"downloading file from worker: %s, reason: %s": "通过 Worker 下载文件: %s,原因: %s",
|
||||
"downloading from origin: %s, reason: %s": "直接从源地址下载: %s,原因: %s",
|
||||
"encode task plugin native submit view failed: %s": "任务插件提交响应: 序列化任务数据失败: %s",
|
||||
"encode task protocol view failed: %s": "任务协议: 序列化任务数据失败: %s",
|
||||
"epay topup": "易支付充值",
|
||||
"epay topup failed: %s": "易支付充值失败: %s",
|
||||
"error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s": "资金来源已结算,调整令牌额度时出错 (userId=%d, tokenId=%d, delta=%d): %s",
|
||||
"error closing websocket connection: %s": "关闭 WebSocket 连接出错: %s",
|
||||
"error consuming Midjourney quota: %s": "Midjourney 扣费出错: %s",
|
||||
"error converting Claude stream response: %s": "转换 Claude 流式响应出错: %s",
|
||||
"error converting final Gemini stream response: %s": "转换 Gemini 流式响应的最后一段出错: %s",
|
||||
"error creating Gemini stream state: %s": "初始化 Gemini 流式转换状态出错: %s",
|
||||
"error finalizing Gemini stream response: %s": "生成 Gemini 流式响应的结束段出错: %s",
|
||||
"error handling last response: %s, lastStreamData: [%s]": "处理最后一段流数据出错: %s, lastStreamData: [%s]",
|
||||
"error handling stream format: %s": "转换并发送流数据出错: %s",
|
||||
"error marshalling chats: %s": "序列化聊天配置出错: %s",
|
||||
"error marshalling model ratio: %s": "序列化模型倍率出错: %s",
|
||||
"error marshalling stream response: %s": "序列化流式响应出错: %s",
|
||||
"error marshalling topup group ratio: %s": "序列化充值分组倍率出错: %s",
|
||||
"error marshalling user groups: %s": "序列化用户分组出错: %s",
|
||||
"error processing stream token data: %s": "统计流数据的 token 出错: %s",
|
||||
"error reading stream response: %s": "读取流式响应出错: %s",
|
||||
"error reading stream: %s": "读取流式响应出错: %s",
|
||||
"error refunding billing source: %s": "退还资金来源出错: %s",
|
||||
"error refunding subscription extra reserved quota: %s": "退还订阅额外预扣的额度出错: %s",
|
||||
"error refunding token quota: %s": "退还令牌额度出错: %s",
|
||||
"error rolling back subscription funding reserve: %s": "回滚订阅预扣额度出错: %s",
|
||||
"error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s": "回滚令牌额度出错 (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
|
||||
"error rolling back wallet funding reserve: %s": "回滚钱包预扣额度出错: %s",
|
||||
"error sending final Gemini stream response: %s": "发送 Gemini 流式响应的最后一段出错: %s",
|
||||
"error sending finalized Gemini stream response: %s": "发送 Gemini 流式响应的结束段出错: %s",
|
||||
"error sending stream response: %s": "发送流式响应出错: %s",
|
||||
"error settling Midjourney quota: %s": "Midjourney 结算出错: %s",
|
||||
"error settling billing: %s": "计费结算出错: %s",
|
||||
"error unmarshalling stream response: %s": "解析流式响应出错: %s",
|
||||
"error: %s": "错误: %s",
|
||||
"error_rendering_final_usage_response: %s": "发送用量数据出错: %s",
|
||||
"error_rendering_stream_response: %s": "发送流式响应出错: %s",
|
||||
"error_scanning_stream_response: %s": "读取流式响应出错: %s",
|
||||
"error_unmarshalling_stream_response: %s": "解析流式响应出错: %s",
|
||||
"expected Claude stream responses, got %T": "这里需要 Claude 流式响应,实际类型是 %T",
|
||||
"fail to decode image config(gif, jpg, png): %s": "解析图片头信息失败 (gif, jpg, png): %s",
|
||||
"fail to decode image config(webp): %s": "解析图片头信息失败 (webp): %s",
|
||||
"fail to get file type from url: %s, error: %s": "获取文件类型失败,URL: %s,错误: %s",
|
||||
"fail to get image from url: %s": "下载图片失败: %s",
|
||||
"failed to add user field: %s": "写入表单字段 user 失败: %s",
|
||||
"failed to adjust funding for the difference, task %s: %s": "差额结算资金调整失败 task %s: %s",
|
||||
"failed to adjust token quota (delta=%d, task=%s): %s": "调整令牌额度失败 (delta=%d, task=%s): %s",
|
||||
"failed to batch update token quota: %s": "批量更新令牌额度失败: %s",
|
||||
"failed to batch update user quota, used quota and request count: %s": "批量更新用户额度、已用额度和请求次数失败: %s",
|
||||
"failed to bind upstream request: %s": "解析上游倍率同步的请求参数失败: %s",
|
||||
"failed to cache refresh-reuse session revoke: %s": "刷新令牌被重复使用,会话已吊销,写入缓存失败: %s",
|
||||
"failed to charge violation fee: %s": "违规扣费失败: %s",
|
||||
"failed to check channel status before closing active websockets: channel_id=%d, error=%v": "关闭 WebSocket 连接前查询渠道状态失败: channel_id=%d, error=%v",
|
||||
"failed to check notification limit: %s": "检查通知频率限制失败: %s",
|
||||
"failed to check the successful request limit: %s": "检查成功请求数限制失败: %s",
|
||||
"failed to check the total request limit: %s": "检查总请求数限制失败: %s",
|
||||
"failed to cleanup expired perf metrics: %s": "清理过期性能指标失败: %s",
|
||||
"failed to clone channel: %s": "复制渠道失败: %s",
|
||||
"failed to close database: %s": "关闭数据库失败: %s",
|
||||
"failed to close response body: %s": "关闭响应体失败: %s",
|
||||
"failed to compensate reserved token quota: result=%d error=%v": "回补缓存中已预扣的令牌额度失败: result=%d error=%v",
|
||||
"failed to compensate reserved user quota: result=%d error=%v": "回补缓存中已预扣的用户额度失败: result=%d error=%v",
|
||||
"failed to configure trusted proxies: %s": "设置可信代理失败: %s",
|
||||
"failed to copy file content: %s": "复制文件内容失败: %s",
|
||||
"failed to copy response body: %s": "复制响应体失败: %s",
|
||||
"failed to count backup codes: %s": "获取备用码数量失败: %s",
|
||||
"failed to count channel types: %s": "统计渠道类型失败: %s",
|
||||
"failed to count channels: %s": "统计渠道数量失败: %s",
|
||||
"failed to count hourly user session issuance: %s": "统计近一小时新建的登录会话数失败: %s",
|
||||
"failed to count search tokens: %s": "统计令牌搜索结果数量失败: %s",
|
||||
"failed to count search topups: %s": "统计充值记录搜索结果数量失败: %s",
|
||||
"failed to count tags: %s": "统计标签数量失败: %s",
|
||||
"failed to count user logs: %s": "统计用户日志数量失败: %s",
|
||||
"failed to count user tokens: %s": "统计用户令牌数量失败: %s",
|
||||
"failed to create disk storage, falling back to memory: %v": "请求体写入磁盘失败,改为保存在内存: %v",
|
||||
"failed to create form file: %s": "创建表单文件失败: %s",
|
||||
"failed to create request: %s": "创建请求失败: %s",
|
||||
"failed to create setup record: %s": "创建系统初始化记录失败: %s",
|
||||
"failed to create temp file: %s": "创建临时文件失败: %s",
|
||||
"failed to create the pprof directory: %s": "创建pprof文件夹失败 %s",
|
||||
"failed to create the pprof file: %s": "创建pprof文件失败 %s",
|
||||
"failed to decode base64: %s": "base64 解码失败: %s",
|
||||
"failed to decode response: %s": "解析响应失败: %s",
|
||||
"failed to decrease token quota: %s": "扣减令牌额度失败: %s",
|
||||
"failed to decrease user quota cache after subscription balance purchase: %s": "余额购买订阅后,扣减用户额度缓存失败: %s",
|
||||
"failed to decrease user quota: %s": "扣减用户额度失败: %s",
|
||||
"failed to delete expired access tokens: %s": "清理过期的访问令牌失败: %s",
|
||||
"failed to delete expired authentication flows: %s": "清理过期的登录验证流程失败: %s",
|
||||
"failed to delete expired user sessions: %s": "清理过期的登录会话失败: %s",
|
||||
"failed to delete old revoked user sessions: %s": "清理超过保留期的已吊销会话失败: %s",
|
||||
"failed to download file from %s, status code: %d": "从 %s 下载文件失败,状态码: %d",
|
||||
"failed to encode log admin info: %s": "序列化日志的 admin_info 失败: %s",
|
||||
"failed to fetch codex reset credits": "获取 Codex 重置次数失败",
|
||||
"failed to fetch codex usage": "获取 Codex 用量失败",
|
||||
"failed to finalize bulk user session revoke tombstone: %s": "批量吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to finalize refresh-authenticated session revoke tombstone: %s": "按刷新令牌吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to finalize user session revoke tombstone: %s": "吊销会话后,写入吊销缓存失败: %s",
|
||||
"failed to flush perf metric bucket model=%s group=%s bucket=%d: %s": "保存性能指标失败 model=%s group=%s bucket=%d: %s",
|
||||
"failed to generate backup codes: %s": "生成备用码失败: %s",
|
||||
"failed to generate the default sidebar config: %s": "生成默认边栏配置失败: %s",
|
||||
"failed to generate token key: %s": "生成令牌密钥失败: %s",
|
||||
"failed to get audio duration: %v": "获取音频时长失败: %v",
|
||||
"failed to get channel by id: %s": "按 ID 查询渠道失败: %s",
|
||||
"failed to get channels by tag: %s": "按标签查询渠道失败: %s",
|
||||
"failed to get channels: %s": "查询渠道失败: %s",
|
||||
"failed to get paginated tags: %s": "分页查询标签失败: %s",
|
||||
"failed to get token by key: %s": "按密钥查询令牌失败: %s",
|
||||
"failed to get token key (tokenId=%d, task=%s): %s": "获取令牌 key 失败 (tokenId=%d, task=%s): %s",
|
||||
"failed to get user quota: %s": "查询用户额度失败: %s",
|
||||
"failed to increase token quota: %s": "增加令牌额度失败: %s",
|
||||
"failed to increase user quota: %s": "增加用户额度失败: %s",
|
||||
"failed to init token cache: %s": "初始化令牌缓存失败: %s",
|
||||
"failed to initialize authorization: %s": "初始化权限控制失败: %s",
|
||||
"failed to initialize database: %s": "初始化数据库失败: %s",
|
||||
"failed to initialize i18n: %s": "初始化 i18n 失败: %s",
|
||||
"failed to initialize password encryption: %s": "初始化密码加密失败: %s",
|
||||
"failed to initialize resources: %s": "初始化资源失败: %s",
|
||||
"failed to insert redemption: %s": "保存兑换码失败: %s",
|
||||
"failed to invalidate token cache after hard deleting user %d: %v": "永久删除用户 %d 后清除令牌缓存失败: %v",
|
||||
"failed to invalidate token cache before batch delete: %s": "批量删除令牌前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before delete: %s": "删除令牌前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before status update: %s": "更新令牌状态前清除缓存失败: %s",
|
||||
"failed to invalidate token cache before update: %s": "更新令牌前清除缓存失败: %s",
|
||||
"failed to invalidate tokens cache for user %d: %s": "清除用户 %d 的令牌缓存失败: %s",
|
||||
"failed to invalidate user cache after hard deleting user %d: %v": "永久删除用户 %d 后清除用户缓存失败: %v",
|
||||
"failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
|
||||
"failed to load tool prices, using hardcoded fallbacks: %s": "加载工具价格失败,将使用硬编码兜底: %s",
|
||||
"failed to marshal gemini response: %s": "序列化 Gemini 响应失败: %s",
|
||||
"failed to marshal log other: %s": "序列化日志 other 字段失败: %s",
|
||||
"failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "序列化渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
|
||||
"failed to marshal setting: %s": "序列化用户设置失败: %s",
|
||||
"failed to marshal setting: channel_id=%d, error=%v": "序列化渠道设置失败: channel_id=%d, error=%v",
|
||||
"failed to migrate options primary key: %s": "为 options 表补主键失败: %s",
|
||||
"failed to migrate retired frontend options: %s": "迁移旧前端的配置项失败: %s",
|
||||
"failed to open log file": "打开日志文件失败",
|
||||
"failed to parse %s: %s, using default value: %d": "环境变量 %s 解析失败: %s,已使用默认值: %d",
|
||||
"failed to parse %s: %s, using default value: %t": "环境变量 %s 解析失败: %s,已使用默认值: %t",
|
||||
"failed to parse CHANNEL_UPDATE_FREQUENCY: %s": "解析 CHANNEL_UPDATE_FREQUENCY 失败: %s",
|
||||
"failed to parse Redis connection string: %s": "解析 Redis 连接字符串失败: %s",
|
||||
"failed to parse auto groups for token %d: %v": "解析令牌 %d 的 Auto 分组失败: %v",
|
||||
"failed to parse oauth key: %s": "解析渠道的 OAuth 凭证失败: %s",
|
||||
"failed to populate newly created user session cache: %s": "新建登录会话后写入缓存失败: %s",
|
||||
"failed to publish auth tombstone after hard deleting user %d: %v": "永久删除用户 %d 后发布登录失效通知失败: %v",
|
||||
"failed to publish websocket close event: %v": "向其他节点广播 WebSocket 关闭事件失败: %v",
|
||||
"failed to query administrators to notify: %s": "查询需要通知的管理员失败: %s",
|
||||
"failed to query channels: %s": "查询渠道失败: %s",
|
||||
"failed to query log stat: %s": "查询日志统计失败: %s",
|
||||
"failed to query rpm/tpm stat: %s": "查询 rpm/tpm 统计失败: %s",
|
||||
"failed to read TTS response body: %v": "读取 TTS 响应体失败: %v",
|
||||
"failed to record log: %s": "记录日志失败: %s",
|
||||
"failed to record task billing log: %s": "记录任务计费日志失败: %s",
|
||||
"failed to record topup log: %s": "记录充值日志失败: %s",
|
||||
"failed to redeem key %s for user %d: %s": "用户 %[2]d 使用兑换码 %[1]s 兑换失败: %[3]s",
|
||||
"failed to refresh codex channel credential: %s": "刷新 Codex 渠道凭证失败: %s",
|
||||
"failed to refresh user group cache after %s for user %d: %v": "刷新用户 %[2]d 的分组缓存失败(操作: %[1]s): %[3]v",
|
||||
"failed to refund Midjourney token quota, task %s: %s": "退还 Midjourney 令牌额度失败 task %s: %s",
|
||||
"failed to refund Midjourney user quota, task %s: %s": "退还 Midjourney 用户额度失败 task %s: %s",
|
||||
"failed to refund the funding source, task %s: %s": "退还资金来源失败 task %s: %s",
|
||||
"failed to reload authz policy: %s": "重新加载授权策略失败: %s",
|
||||
"failed to reset codex usage": "重置 Codex 用量失败",
|
||||
"failed to save backup codes: %s": "保存备用码失败: %s",
|
||||
"failed to search tokens: %s": "搜索令牌失败: %s",
|
||||
"failed to search topups: %s": "搜索充值记录失败: %s",
|
||||
"failed to search user logs: %s": "搜索用户日志失败: %s",
|
||||
"failed to send email to %s: %v": "向 %s 发送邮件失败: %v",
|
||||
"failed to send notice %s to user %d: %s": "向用户 %[2]d 发送通知 %[1]s 失败: %[3]s",
|
||||
"failed to send password reset email to %s: %s": "向 %s 发送密码重置邮件失败: %s",
|
||||
"failed to send quota notify to user %d: %s": "向用户 %d 发送额度通知失败: %s",
|
||||
"failed to send request: %s": "发送请求失败: %s",
|
||||
"failed to send subscription quota notify to user %d: %s": "向用户 %d 发送订阅额度通知失败: %s",
|
||||
"failed to serialize VideoUrls: %v": "序列化 VideoUrls 失败: %v",
|
||||
"failed to start HTTP server: %s": "HTTP 服务启动失败: %s",
|
||||
"failed to start pprof: %s": "启动pprof失败 %s",
|
||||
"failed to sync %s credit to user quota cache: %s": "把到账额度同步到用户额度缓存失败(操作: %s): %s",
|
||||
"failed to sync manual quota adjustment for user %d: %s": "把用户 %d 的手动调额同步到缓存失败: %s",
|
||||
"failed to synchronously populate user cache: %s": "同步写入用户缓存失败: %s",
|
||||
"failed to synchronously populate user session cache: %s": "同步写入用户会话缓存失败: %s",
|
||||
"failed to unmarshal Claude stream event: %s": "解析 Claude 流事件失败: %s",
|
||||
"failed to unmarshal buffered responses stream event: %s": "解析已缓冲的 Responses 流事件失败: %s",
|
||||
"failed to unmarshal chat stream response: %s": "解析 Chat 流式响应失败: %s",
|
||||
"failed to unmarshal header override: channel_id=%d, error=%v": "解析渠道的请求头覆盖失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "解析渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
|
||||
"failed to unmarshal param override: channel_id=%d, error=%v": "解析渠道的参数覆盖失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal responses stream event: %s": "解析 Responses 流事件失败: %s",
|
||||
"failed to unmarshal setting: %s": "解析用户设置失败: %s",
|
||||
"failed to unmarshal setting: channel_id=%d, error=%v": "解析渠道设置失败: channel_id=%d, error=%v",
|
||||
"failed to unmarshal stream response: %s": "解析流式响应失败: %s",
|
||||
"failed to unmarshal user: %s": "解析 user 失败: %s",
|
||||
"failed to unmarshal websocket close event: %v": "解析 WebSocket 关闭事件失败: %v",
|
||||
"failed to update abilities: channel_id=%d, tag=%s, error=%v": "更新渠道的 abilities 记录失败: channel_id=%d, tag=%s, error=%v",
|
||||
"failed to update ability status: channel_id=%d, error=%v": "更新渠道的 abilities 状态失败: channel_id=%d, error=%v",
|
||||
"failed to update balance: channel_id=%d, error=%v": "更新余额失败: channel_id=%d, error=%v",
|
||||
"failed to update channel status: channel_id=%d, status=%d, error=%v": "更新渠道状态失败: channel_id=%d, status=%d, error=%v",
|
||||
"failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v": "更新渠道已用额度失败: channel_id=%d, delta_quota=%d, error=%v",
|
||||
"failed to update config %s: %s": "更新配置 %s 失败: %s",
|
||||
"failed to update multi-key status: channel_id=%d, using key not found": "更新多密钥状态失败: channel_id=%d,密钥列表里找不到本次使用的密钥",
|
||||
"failed to update option map: %s": "加载配置项失败: %s",
|
||||
"failed to update response time: channel_id=%d, error=%v": "更新响应时间失败: channel_id=%d, error=%v",
|
||||
"failed to update rotated user session cache: %s": "续期登录后更新会话缓存失败: %s",
|
||||
"failed to update token status%s": "更新令牌状态失败: %s",
|
||||
"failed to update user group cache: %s": "更新用户分组缓存失败: %s",
|
||||
"failed to update user last_login_at: %s": "更新用户最后登录时间失败: %s",
|
||||
"failed to update user name cache: %s": "更新用户名缓存失败: %s",
|
||||
"failed to update user setting cache: %s": "更新用户设置缓存失败: %s",
|
||||
"failed to update user used quota and request count: %s": "更新用户已用额度和请求次数失败: %s",
|
||||
"failed to update user used quota: %s": "更新用户已用额度失败: %s",
|
||||
"failed to validate cloned channel: %s": "复制出的渠道未通过校验: %s",
|
||||
"failed to write TTS response: %v": "写入 TTS 响应失败: %v",
|
||||
"failed to write back the settled task quota, task %s: %s": "差额结算回写 quota 失败 task %s: %s",
|
||||
"failed to write stream data: %s": "写入流数据失败: %s",
|
||||
"failed to write to temp file: %s": "写入临时文件失败: %s",
|
||||
"final group ratio: %f": "最终分组倍率: %f",
|
||||
"final group: %s": "最终使用的分组: %s",
|
||||
"getAndValidateTextRequest failed: %s": "getAndValidateTextRequest 失败: %s",
|
||||
"get_channel_null: %s": "查询渠道失败: %s",
|
||||
"group ratio not found: %s": "未找到分组倍率: %s",
|
||||
"hourly user session issuance exceeded alert threshold: count=%d threshold=%d window_seconds=%d": "近一小时新建的登录会话数超过告警阈值: count=%d threshold=%d window_seconds=%d",
|
||||
"http error on %s: %s": "请求 %s 出错: %s",
|
||||
"http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s": "HTTP 连接协商结果: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
|
||||
"http transport select: host=%s protocol=%s shards=%d policy=%s": "HTTP 连接选择: host=%s protocol=%s shards=%d policy=%s",
|
||||
"http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s": "HTTP 连接: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s",
|
||||
"i18n initialized with languages: %s": "i18n 已初始化,语言: %s",
|
||||
"image request body: %s": "图片请求体: %s",
|
||||
"image token input: format=%s, width=%d, height=%d": "计算图片 token: format=%s, width=%d, height=%d",
|
||||
"increaseQuotaData error: %s": "increaseQuotaData 出错: %s",
|
||||
"initialize legacy access token deadline: %s": "设置旧访问令牌的停用时间失败: %s",
|
||||
"initialized the sidebar config for new user %s (role: %d)": "为新用户 %s (角色: %d) 初始化边栏配置",
|
||||
"insert task error: %s": "保存任务失败: %s",
|
||||
"invalid SQL_SLOW_THRESHOLD_MS %d (allowed 0-%d, 0 disables slow query log), using default %d": "SQL_SLOW_THRESHOLD_MS=%d 超出范围(可填 0 到 %d,0 表示关闭慢查询日志),已使用默认值 %d",
|
||||
"invalid access token scopes: %s": "访问令牌的作用域无效: %s",
|
||||
"invalid channel http transport setting clamped: %s=%q": "渠道的 HTTP 连接设置超出范围,已调整为允许的值: %s=%q",
|
||||
"invalid image cache token breakdown; using aggregate cache billing": "图片缓存 token 的分项数据无效,按缓存 token 总量计费",
|
||||
"invalid image stream response": "图片流式响应无效",
|
||||
"invalid request policy: %s": "请求策略无效: %s",
|
||||
"invalid response or response body": "上游响应为空或没有响应体",
|
||||
"invalid task artifact store configuration: %s; using upstream mode": "任务制品存储配置无效: %s,改用 upstream 模式",
|
||||
"invalid thinking_model_blacklist regex %q: %v": "thinking_model_blacklist 正则 %q 无效: %v",
|
||||
"invalid thinking_model_blacklist regex %q: pattern is empty": "thinking_model_blacklist 正则 %q 无效: 表达式为空",
|
||||
"invalid zhipu key: %s": "智谱密钥格式无效: %s",
|
||||
"json decode failed from %s: %s": "解析 %s 返回的 JSON 失败: %s",
|
||||
"json marshal failed: %s": "JSON 序列化失败: %s",
|
||||
"legacy Uptime Kuma options were not migrated: %v": "旧的 Uptime Kuma 配置迁移失败: %v",
|
||||
"legacy Uptime Kuma options were not migrated: both URL and slug are required": "旧的 Uptime Kuma 配置没有迁移: URL 和 slug 都需要填写",
|
||||
"legacy option %s was not migrated: %v": "旧配置项 %s 迁移失败: %v",
|
||||
"legacy proxy URL suffix ignored at runtime: scheme=%s host=%s; update the channel proxy setting": "渠道代理地址带有旧写法的后缀,运行时已忽略: scheme=%s host=%s。请修改渠道的代理设置",
|
||||
"load advanced custom channel settings error: channel_id=%d, error=%v": "加载高级自定义渠道设置出错: channel_id=%d, error=%v",
|
||||
"loadFromURL: decoding image config": "loadFromURL: 解析图片头信息",
|
||||
"loadFromURL: initiating download": "loadFromURL: 开始下载",
|
||||
"loadFromURL: reading response body": "loadFromURL: 读取响应体",
|
||||
"manual topup": "管理员补单",
|
||||
"memory cache enabled": "已启用内存缓存",
|
||||
"midjourney response body: %s": "Midjourney 响应体: %s",
|
||||
"model %s is free, skipping pre-consume": "模型 %s 免费,跳过预扣费",
|
||||
"model price not found: %s": "未找到模型价格: %s",
|
||||
"model_price_helper result: %s": "model_price_helper 结果: %s",
|
||||
"model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s": "model_price_helper_tiered 结果: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s",
|
||||
"models.dev parse failed from %s: %s": "解析 %s 返回的 models.dev 数据失败: %s",
|
||||
"no such user %s": "用户 %s 不存在",
|
||||
"no user exists, create a root user for you: username is root, password is 123456": "系统里还没有用户,已自动创建 root 账号:用户名 root,密码 123456",
|
||||
"non-200 from %s: %s": "%s 返回了非 200 状态: %s",
|
||||
"ollama stream json decode error: %s line=%s": "Ollama 流式响应 JSON 解析失败: %s line=%s",
|
||||
"ollama stream scan error: %s": "Ollama 流式响应读取出错: %s",
|
||||
"openrouter enterprise response success=false, data: %s": "OpenRouter Enterprise 响应 success=false, data: %s",
|
||||
"options key %q had conflicting values; keeping the last row": "options 表的键 %q 有多行且值不同,保留最后一行的值",
|
||||
"options table has duplicate pricing keys [%s]; the table is missing a primary key": "options 表缺少主键,定价配置出现重复的键 [%s]",
|
||||
"panic detected: %v": "发生 panic: %v",
|
||||
"panic in gopool.RelayPool: %v": "gopool.RelayPool 中发生 panic: %v",
|
||||
"panic recovered in plugin route": "插件路由发生 panic,已恢复",
|
||||
"passkey verification rejected: code=%s reason=%s rp_id=%q": "通行密钥验证未通过: code=%s reason=%s rp_id=%q",
|
||||
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d": "用户已确认支付合规条款 user_id=%d ip=%s terms_version=%s confirmed_at=%d",
|
||||
"persist task background flag failed: %s": "保存任务的后台运行标记失败: %s",
|
||||
"ping data error: %s": "发送 ping 数据出错: %s",
|
||||
"ping data sent": "ping 数据已发送",
|
||||
"ping goroutine exited": "ping goroutine 已退出",
|
||||
"ping goroutine max duration reached": "ping goroutine 运行达到最长时限,已停止",
|
||||
"ping goroutine panic: %v": "ping goroutine 发生 panic: %v",
|
||||
"ping interval seconds: %d": "ping 间隔: %d 秒",
|
||||
"pinned task plugin does not support the requested protocol form": "选定的任务插件不支持本次请求的调用方式(流式、后台或同步)",
|
||||
"pprof enabled": "已启用 pprof",
|
||||
"pre-consumed quota matches the actual quota, no adjustment: %s (per-request billing)": "预扣费与实际消耗一致,无需调整:%s(按次计费)",
|
||||
"quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s": "消费日志: 额度超出上限,已按上限记录: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
|
||||
"rate limit check failed (key=%s): %v": "限流检查失败 (key=%s): %v",
|
||||
"rate limit check failed (mark=%s): %v": "限流检查失败 (mark=%s): %v",
|
||||
"read response failed from %s: %s": "读取 %s 的响应失败: %s",
|
||||
"ready in %d ms": "启动完成,耗时 %d ms",
|
||||
"realtime error: %s": "Realtime 出错: %s",
|
||||
"realtime streaming consume quota success, quota: %d": "Realtime 扣费成功,额度: %d",
|
||||
"realtime streaming localUsage: %v": "Realtime 流式 localUsage: %v",
|
||||
"realtime streaming sumUsage: %v": "Realtime 流式 sumUsage: %v",
|
||||
"rebuild task alias view: %s": "重建任务模型别名表失败: %s",
|
||||
"rebuilt options table with primary key from %d rows into %d keys; previous rows kept in %s": "已重建 options 表并加上主键,%d 行合并成 %d 个键,重建前的数据保存在 %s",
|
||||
"received [DONE], stopping scanner": "收到 [DONE],停止读取流",
|
||||
"received signal: %v, shutting down...": "收到信号 %v,正在关闭服务...",
|
||||
"record consume log: userId=%d, params=%s": "记录消费日志: userId=%d, params=%s",
|
||||
"record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s": "记录错误日志: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s",
|
||||
"redemption": "兑换码兑换",
|
||||
"redemption failed: %s": "兑换失败: %s",
|
||||
"refund succeeded but clearing the task quota failed, task %s: %s": "退款成功但清除 task quota 失败 task %s: %s",
|
||||
"refunding the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
|
||||
"relay error (channel #%d, status code %d): %s": "转发请求出错 (渠道 #%d,状态码 %d): %s",
|
||||
"relay error: %s": "转发请求出错: %s",
|
||||
"relay max idle conns per host: %d": "转发连接池每个主机的最大空闲连接数: %d",
|
||||
"relay max idle conns: %d": "转发连接池的最大空闲连接数: %d",
|
||||
"relay timeout seconds: %d": "转发超时: %d 秒",
|
||||
"reranker response body: %s": "重排序响应体: %s",
|
||||
"reserve adjusted task billing error: %s": "按调整后的任务费用补扣预扣费失败: %s",
|
||||
"responses stream failed after output was delivered, billing the delivered output: %s": "Responses 流中途失败,已输出的内容照常计费: %s",
|
||||
"responses websocket relay error: %s": "Responses WebSocket 转发出错: %s",
|
||||
"retry: %s": "重试:%s",
|
||||
"running in debug mode": "调试模式已开启",
|
||||
"saved dashboard data, %d records": "保存数据看板数据成功,共保存%d条数据",
|
||||
"scanner error: %s": "读取流出错: %s",
|
||||
"scanner goroutine exited": "scanner goroutine 已退出",
|
||||
"scanner goroutine panic: %v": "scanner goroutine 发生 panic: %v",
|
||||
"send final Gemini stream response failed: %s": "发送 Gemini 流式响应的最后一段失败: %s",
|
||||
"send final response failed: %s": "发送最后一段响应失败: %s",
|
||||
"send_stream_response_failed: %s": "发送流式响应失败: %s",
|
||||
"server exited": "服务已退出",
|
||||
"server forced to shutdown: %v": "服务未能正常关闭,已强制退出: %v",
|
||||
"settle task billing error: %s": "任务计费结算出错: %s",
|
||||
"setup log is already working": "日志正在初始化,跳过本次调用",
|
||||
"skip password reset email for %s: %s": "未向 %s 发送密码重置邮件: %s",
|
||||
"skipped %d options rows with empty keys while rebuilding the primary key": "重建主键时跳过了 %d 行键为空的 options 记录",
|
||||
"stacktrace from panic: %s": "panic 堆栈: %s",
|
||||
"start pyroscope error : %v": "启动 pyroscope 出错: %v",
|
||||
"stream ended: %s": "流式响应结束: %s",
|
||||
"stream ended: %s, received=%d": "流式响应结束: %s, received=%d",
|
||||
"stream event error: %v %v": "流事件报错: %v %v",
|
||||
"stream scanner data: %s": "收到流数据: %s",
|
||||
"streaming timeout seconds: %d": "流式超时: %d 秒",
|
||||
"stripe topup": "Stripe 充值",
|
||||
"subscription balance purchase": "余额购买订阅",
|
||||
"subscription expiration": "订阅到期",
|
||||
"subscription expire task failed: %v": "处理过期订阅失败: %v",
|
||||
"subscription maintenance: reset_count=%d, expired_count=%d": "订阅维护: reset_count=%d, expired_count=%d",
|
||||
"subscription payment completion": "订阅支付完成",
|
||||
"subscription quota reset task failed: %v": "重置订阅额度失败: %v",
|
||||
"subscription quota reset task started: tick=%s": "订阅额度重置任务已启动: tick=%s",
|
||||
"sweepTimedOutTasks CAS update error for task %s: %v": "sweepTimedOutTasks: 更新任务 %s 的状态出错: %v",
|
||||
"sweepTimedOutTasks: task %s already transitioned, skip": "sweepTimedOutTasks: 任务 %s 的状态已由其他进程更新,跳过",
|
||||
"sweepTimedOutTasks: timed out %d tasks": "sweepTimedOutTasks: 已把 %d 个超时任务标记为失败",
|
||||
"sync frequency: %d seconds": "同步间隔: %d 秒",
|
||||
"syncing channels from database": "正在从数据库同步渠道",
|
||||
"syncing options from database": "正在从数据库同步配置项",
|
||||
"system instance report failed: %v": "上报本实例信息失败: %v",
|
||||
"system is already initialized at: %s": "系统已于 %s 完成初始化",
|
||||
"system is not initialized and no root user exists": "系统尚未初始化,还没有 root 用户",
|
||||
"system is not initialized, but root user exists": "系统尚未初始化,但已有 root 用户",
|
||||
"system task %s failed to persist result: %v": "系统任务 %s 保存结果失败: %v",
|
||||
"system task %s failed to save failure state: %v": "系统任务 %s 保存失败状态时出错: %v",
|
||||
"system task %s failed: %v": "系统任务 %s 失败: %v",
|
||||
"system task %s lock lost": "系统任务 %s 的执行锁已失效",
|
||||
"system task %s update failed: %v": "系统任务 %s 更新失败: %v",
|
||||
"system task claim failed: %v": "领取系统任务失败: %v",
|
||||
"system task runner query failed: %v": "查询待执行的系统任务失败: %v",
|
||||
"system task runner started: runner=%s idle_interval=%s": "系统任务执行器已启动: runner=%s idle_interval=%s",
|
||||
"system task scheduler active lookup failed: type=%s err=%v": "系统任务调度: 查询进行中的任务失败: type=%s err=%v",
|
||||
"system task scheduler create failed: type=%s err=%v": "系统任务调度: 创建任务失败: type=%s err=%v",
|
||||
"system task scheduler query failed: %v": "系统任务调度: 查询失败: %v",
|
||||
"system task stale lock cleanup failed: %v": "清理系统任务的过期执行锁失败: %v",
|
||||
"task %s expression settlement failed, keeping the pre-consumed quota: %v": "任务 %s 表达式结算失败,保留预扣额度: %v",
|
||||
"task %s expression settlement quota was clamped: %+v": "任务 %s 表达式结算额度发生饱和: %+v",
|
||||
"task %s is billed per request, skipping difference settlement": "任务 %s 按次计费,跳过差额结算",
|
||||
"task %s poll %s (failures=%d, http=%d): %s": "任务 %s 轮询结果异常: %s (failures=%d, http=%d): %s",
|
||||
"task %s pre-consumed quota matches the actual quota (%s, %s)": "任务 %s 预扣费准确(%s,%s)",
|
||||
"task %s settles the difference: delta=%s (actual %s, pre-consumed %s, %s)": "任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
|
||||
"task alias mapping cycle dropped: channel=%d key=%q": "渠道的模型映射成环,这个任务模型别名已忽略: channel=%d key=%q",
|
||||
"task alias view: channel %d model_mapping: %s": "解析渠道 %d 的 model_mapping 失败,任务模型别名未包含该渠道: %s",
|
||||
"task artifact S3 storage is not implemented; using upstream mode": "任务制品 S3 存储尚未实现,改用 upstream 模式",
|
||||
"task billing other ratio key rejected: %s": "任务计费: 倍率键 %s 不能写入日志,已跳过",
|
||||
"task immediate usage settlement failed; retaining reserved quota: %v": "任务提交即完成,按用量结算失败,保留预扣额度: %v",
|
||||
"task model alias %q dropped: maps to multiple plugins %v": "任务模型别名 %q 对应多个插件 %v,已忽略",
|
||||
"task plugin %s completion context failed; retaining reserved quota: %v": "任务插件 %s: 构建完成结算的上下文失败,保留预扣额度: %v",
|
||||
"task plugin %s completion usage failed; retaining reserved quota: %v": "任务插件 %s: 提取完成用量失败,保留预扣额度: %v",
|
||||
"task plugin %s completion usage rejected; retaining reserved quota: %v": "任务插件 %s: 返回的完成用量无效,保留预扣额度: %v",
|
||||
"task plugin %s rejected invalid %s billing facts": "任务插件 %s 的 %s 钩子返回了无效的计费数据,已忽略",
|
||||
"task plugin %s rejected invalid or oversized poll state": "任务插件 %s 返回的轮询状态无效或过大,未保存",
|
||||
"task plugin %s rejected oversized poll state (%d bytes)": "任务插件 %s 返回的轮询状态过大(%d 字节),未保存",
|
||||
"task plugin %s rejected oversized submit state (%d bytes)": "任务插件 %s 返回的提交状态过大(%d 字节),未保存",
|
||||
"task plugin native submit presenter failed: %s": "任务插件提交响应: 渲染钩子执行失败: %s",
|
||||
"task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s": "任务插件路由 %s %s 声明了 retainResult: false,但返回的是异步结果,任务 %s 仍会保存",
|
||||
"task poll auth failure channel_id=%d http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d http=%d",
|
||||
"task poll auth failure channel_id=%d task=%s http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d task=%s http=%d",
|
||||
"task progress polling finished": "任务进度轮询完成",
|
||||
"task progress polling started": "任务进度轮询开始",
|
||||
"task protocol database observation failed": "任务协议: 读取任务状态失败",
|
||||
"task protocol database observation overloaded; plugin=%s task=%s": "任务协议: 读取任务状态超时,数据库繁忙 plugin=%s task=%s",
|
||||
"task protocol failure event failed: %s": "任务协议: 生成失败事件出错: %s",
|
||||
"task protocol failure response failed: %s": "任务协议: 生成失败响应出错: %s",
|
||||
"task protocol final hook failed": "任务协议: 插件的最终响应钩子执行失败",
|
||||
"task protocol final hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,最终响应钩子等待超时 plugin=%s task=%s",
|
||||
"task protocol image download failed; returning the upstream URL instead of b64_json: %s": "任务协议: 图片下载失败,b64_json 改用上游 URL 返回: %s",
|
||||
"task protocol image poll failed for task %s: %s": "任务协议: 轮询图片任务 %s 失败: %s",
|
||||
"task protocol image render hook failed: %s": "任务协议: 插件的图片渲染钩子执行失败: %s",
|
||||
"task protocol image render hook must return an object with a data array": "任务协议: 插件的图片渲染钩子需要返回带 data 数组的对象",
|
||||
"task protocol render hook failed": "任务协议: 插件的渲染钩子执行失败",
|
||||
"task protocol render hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,渲染钩子等待超时 plugin=%s task=%s",
|
||||
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d": "任务协议: 插件的渲染钩子执行过慢 plugin=%s task=%s elapsed_ms=%d",
|
||||
"task protocol render result invalid: %s": "任务协议: 插件渲染钩子返回的结果无效: %s",
|
||||
"task protocol retrieve lookup failed": "任务协议: 查询任务失败",
|
||||
"task protocol retrieve render failed": "任务协议: 生成查询响应失败",
|
||||
"task protocol state transition failed: %s": "任务协议: 状态切换失败: %s",
|
||||
"task protocol submission returned an invalid durable outcome": "任务协议: 提交后没有得到有效的任务记录",
|
||||
"task protocol terminal failure response failed: %s": "任务协议: 生成任务失败的最终响应出错: %s",
|
||||
"task protocol timeout event failed: %s": "任务协议: 生成超时事件出错: %s",
|
||||
"task protocol timeout response failed: %s": "任务协议: 生成超时响应出错: %s",
|
||||
"testing channel #%d, response: \n%s": "测试渠道 #%d,响应: \n%s",
|
||||
"testing channel %d with model %s , info %+v ": "测试渠道 %d,模型 %s,信息 %+v ",
|
||||
"text request body: %s": "文本请求体: %s",
|
||||
"token quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣令牌额度,改为直接扣数据库: %s",
|
||||
"tool surcharge call count clamped: tool=%s count=%d": "工具调用次数超出计费上限,按上限计费: tool=%s count=%d",
|
||||
"tool surcharge call count overflow for %s": "工具 %s 的计费调用次数溢出",
|
||||
"topup failed: %s": "充值失败: %s",
|
||||
"topup group ratio not found: %s": "未找到充值分组倍率: %s",
|
||||
"total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d": "token 总数为 0,无法扣费,userId %d, channelId %d, tokenId %d, model %s,预扣额度 %d",
|
||||
"try to decode image config with limit: %d": "读取前 %d 字节来解析图片头信息",
|
||||
"unexpected content-type from %s: %s": "%s 返回的 content-type 不符合预期: %s",
|
||||
"unfinished tasks found: %v": "检测到未完成的任务数有: %v",
|
||||
"union is nil or unknown type": "union 为 nil 或类型未知",
|
||||
"unknown tag: %v": "未知 tag: %v",
|
||||
"unrecognized data format from %s: %s": "无法识别 %s 返回的数据格式: %s",
|
||||
"updateVideoSingleTask parsed as new api response format: %+v": "updateVideoSingleTask 按 New API 响应格式解析: %+v",
|
||||
"updateVideoSingleTask response: %s": "updateVideoSingleTask 响应: %s",
|
||||
"updating all channels": "开始更新所有渠道的余额",
|
||||
"updating dashboard data...": "正在更新数据看板数据...",
|
||||
"upstream model update check failed: channel_id=%d channel_name=%s err=%v": "检查渠道的上游模型变化失败: channel_id=%d channel_name=%s err=%v",
|
||||
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d": "24 小时内已发过相同内容的上游模型更新通知,本次不发送: changed_channels=%d failed_channels=%d",
|
||||
"upstream model update notifications sent: %d": "已发送 %d 条上游模型更新通知",
|
||||
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d": "上游模型更新检查完成: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
|
||||
"upstream model update task query failed: %v": "上游模型更新检查: 查询渠道失败: %v",
|
||||
"upstream response body: %s": "上游响应体: %s",
|
||||
"usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d": "从倒数第二条 SSE 数据中取得用量: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
"user %d has enough quota, trusted without pre-consume (funding=%s)": "用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)",
|
||||
"user %d has no bark url, skip sending bark": "用户 %d 没有填写 Bark 推送地址,未发送 Bark 通知",
|
||||
"user %d has no email, skip sending email": "用户 %d 没有绑定邮箱,未发送邮件通知",
|
||||
"user %d has no gotify url or token, skip sending gotify": "用户 %d 没有填写 Gotify 地址或令牌,未发送 Gotify 通知",
|
||||
"user %d has no webhook url, skip sending webhook": "用户 %d 没有填写 Webhook 地址,未发送 Webhook 通知",
|
||||
"user %d needs pre-consume %s (funding=%s)": "用户 %d 需要预扣费 %s (funding=%s)",
|
||||
"user %d request failed, refunding the pre-consumed quota (token_quota=%s, funding=%s)": "用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
|
||||
"user %d | %s": "用户 %d | %s",
|
||||
"user quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣用户额度,改为直接扣数据库: %s",
|
||||
"using ClickHouse as log database": "日志数据库使用 ClickHouse",
|
||||
"using MySQL as database": "数据库使用 MySQL",
|
||||
"using PostgreSQL as database": "数据库使用 PostgreSQL",
|
||||
"waffo pancake topup": "Waffo Pancake 充值",
|
||||
"waffo pancake topup failed: %s": "Waffo Pancake 充值失败: %s",
|
||||
"waffo topup": "Waffo 充值",
|
||||
"waffo topup failed: %s": "Waffo 充值失败: %s",
|
||||
"websocket connection is nil": "WebSocket 连接为空",
|
||||
"zhipu_image_empty_b64": "智谱图片接口返回的 b64 为空",
|
||||
"zhipu_image_get_b64_failed: %s": "智谱图片转 b64 失败: %s",
|
||||
"zhipu_image_missing_url": "智谱图片接口没有返回 URL",
|
||||
"¥%.6f quota": "¥%.6f 额度",
|
||||
"$%.6f quota": "$%.6f 额度"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Message is text shown in the web console. Key is the English source text and
|
||||
// also its key in web/src/i18n/locales; Params fill its {{name}} placeholders.
|
||||
// The web console translates Key with Params, so the backend only renders the
|
||||
// English text, for API callers and log fallbacks. Params carry display-ready
|
||||
// values: format quota with logger.FormatQuota before passing it.
|
||||
type Message struct {
|
||||
Key string `json:"key"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
func NewMessage(key string, params ...map[string]any) *Message {
|
||||
message := &Message{Key: key}
|
||||
if len(params) > 0 {
|
||||
message.Params = params[0]
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Error renders the English text, so a Message can be returned as an error and
|
||||
// reach the web console through ApiError.
|
||||
func (m *Message) Error() string {
|
||||
if len(m.Params) == 0 {
|
||||
return m.Key
|
||||
}
|
||||
// One pass over the key, so a value that contains "{{name}}" stays as is.
|
||||
pairs := make([]string, 0, len(m.Params)*2)
|
||||
for name, value := range m.Params {
|
||||
pairs = append(pairs, "{{"+name+"}}", fmt.Sprint(value))
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(m.Key)
|
||||
}
|
||||
|
||||
// Fields returns the response fields the web console translates. Callers put
|
||||
// the English text in the field their response shape already uses.
|
||||
func (m *Message) Fields() gin.H {
|
||||
fields := gin.H{"message_key": m.Key}
|
||||
if len(m.Params) > 0 {
|
||||
fields["message_params"] = m.Params
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -12,18 +12,24 @@ var (
|
||||
ImageGenerationModels = []string{
|
||||
"dall-e-3",
|
||||
"dall-e-2",
|
||||
"gpt-image-1",
|
||||
"prefix:dall-e", // Deprecated upstream models; retained for compatible routes.
|
||||
"gpt-image-",
|
||||
"qwen-image",
|
||||
"z-image",
|
||||
"wan2.7-image-pro",
|
||||
"wan2.7-image",
|
||||
"wan2.6-image",
|
||||
"wan2.6-t2i",
|
||||
"wan2.5-t2i-preview",
|
||||
"wan2.2-t2i-flash",
|
||||
"wan2.2-t2i-plus",
|
||||
"wanx2.1-t2i-turbo",
|
||||
"wanx2.1-t2i-plus",
|
||||
"wanx2.0-t2i-turbo",
|
||||
"prefix:imagen-",
|
||||
"flux-",
|
||||
"flux.1-",
|
||||
}
|
||||
OpenAITextModels = []string{
|
||||
"gpt-",
|
||||
"o1",
|
||||
"o3",
|
||||
"o4",
|
||||
"chatgpt",
|
||||
}
|
||||
)
|
||||
|
||||
func IsOpenAIResponseOnlyModel(modelName string) bool {
|
||||
@@ -38,19 +44,12 @@ func IsOpenAIResponseOnlyModel(modelName string) bool {
|
||||
func IsImageGenerationModel(modelName string) bool {
|
||||
modelName = strings.ToLower(modelName)
|
||||
for _, m := range ImageGenerationModels {
|
||||
if strings.Contains(modelName, m) {
|
||||
return true
|
||||
if prefix, ok := strings.CutPrefix(m, "prefix:"); ok {
|
||||
if strings.HasPrefix(modelName, prefix) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsOpenAITextModel(modelName string) bool {
|
||||
modelName = strings.ToLower(modelName)
|
||||
for _, m := range OpenAITextModels {
|
||||
if strings.Contains(modelName, m) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWanEndpointsDistinguishImagesFromVideos(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"wan2.7-image-pro", "wan2.7-image", "wan2.6-image", "wan2.6-t2i",
|
||||
"wan2.5-t2i-preview", "wan2.2-t2i-flash", "wan2.2-t2i-plus",
|
||||
"wanx2.1-t2i-turbo", "wanx2.1-t2i-plus", "wanx2.0-t2i-turbo",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.Contains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration)
|
||||
})
|
||||
}
|
||||
for _, name := range []string{
|
||||
"wanx2.1-t2v-plus", "wanx2.1-t2v-turbo", "wanx2.1-i2v-plus", "wanx2.1-i2v-turbo",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.NotContains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// /v1/models reports the DeepSeek Search and Fetch routing names with their
|
||||
// own endpoint on the channels that relay them; chat models keep their list.
|
||||
func TestDeepSeekToolRoutingNamesReportTheirEndpoint(t *testing.T) {
|
||||
for _, channelType := range []int{constant.ChannelTypeDeepSeek, constant.ChannelTypeNewAPI} {
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekSearchModel))
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekFetchModel))
|
||||
}
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, common.GetEndpointTypesByChannelType(constant.ChannelTypeDeepSeek, "deepseek-flash"))
|
||||
}
|
||||
@@ -17,23 +17,23 @@ func Monitor() {
|
||||
panic(err)
|
||||
}
|
||||
if percent[0] > 80 {
|
||||
fmt.Println("cpu usage too high")
|
||||
fmt.Println(LogText("cpu usage too high"))
|
||||
// write pprof file
|
||||
if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
|
||||
err := os.Mkdir("./pprof", os.ModePerm)
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件夹失败 " + err.Error())
|
||||
SysLog(LogText("failed to create the pprof directory: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
}
|
||||
f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件失败 " + err.Error())
|
||||
SysLog(LogText("failed to create the pprof file: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
err = pprof.StartCPUProfile(f)
|
||||
if err != nil {
|
||||
SysLog("启动pprof失败 " + err.Error())
|
||||
SysLog(LogText("failed to start pprof: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
time.Sleep(10 * time.Second) // profile for 30 seconds
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package common
|
||||
|
||||
func GetTrustQuota() int {
|
||||
return int(10 * QuotaPerUnit)
|
||||
}
|
||||
@@ -1,70 +1,211 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimitRequest stores one accepted request in a key's sliding window.
|
||||
type rateLimitRequest struct {
|
||||
next *rateLimitRequest
|
||||
timestamp int64
|
||||
}
|
||||
|
||||
// rateLimitQueue keeps accepted requests ordered from oldest to newest.
|
||||
type rateLimitQueue struct {
|
||||
head *rateLimitRequest
|
||||
tail *rateLimitRequest
|
||||
length int
|
||||
}
|
||||
|
||||
// append adds an accepted request without preallocating for the configured limit.
|
||||
func (q *rateLimitQueue) append(timestamp int64) {
|
||||
request := &rateLimitRequest{timestamp: timestamp}
|
||||
if q.tail == nil {
|
||||
q.head = request
|
||||
q.tail = request
|
||||
} else {
|
||||
q.tail.next = request
|
||||
q.tail = request
|
||||
}
|
||||
q.length++
|
||||
}
|
||||
|
||||
// removeExpired releases every expired request at the front of the queue.
|
||||
func (q *rateLimitQueue) removeExpired(now int64, duration int64) {
|
||||
if q.head == nil || now-q.head.timestamp < duration {
|
||||
return
|
||||
}
|
||||
|
||||
// Requests are time ordered, so an expired tail means the whole queue expired.
|
||||
if now-q.tail.timestamp >= duration {
|
||||
q.clear()
|
||||
return
|
||||
}
|
||||
|
||||
for now-q.head.timestamp >= duration {
|
||||
expired := q.head
|
||||
q.head = expired.next
|
||||
expired.next = nil
|
||||
q.length--
|
||||
}
|
||||
}
|
||||
|
||||
// clear releases the whole request chain when its key expires.
|
||||
func (q *rateLimitQueue) clear() {
|
||||
q.head = nil
|
||||
q.tail = nil
|
||||
q.length = 0
|
||||
}
|
||||
|
||||
// rateLimitEntry is both a rate-limit bucket and a node in the key-level LRU.
|
||||
type rateLimitEntry struct {
|
||||
lastActive time.Time
|
||||
element *list.Element
|
||||
requests rateLimitQueue
|
||||
key string
|
||||
}
|
||||
|
||||
// InMemoryRateLimiter implements a sliding-window limiter with idle-key eviction.
|
||||
// Reservations are tracked apart from the LRU so an in-flight request survives
|
||||
// its key being evicted while it is still running.
|
||||
type InMemoryRateLimiter struct {
|
||||
store map[string]*[]int64
|
||||
store map[string]*rateLimitEntry
|
||||
lru *list.List
|
||||
reservations map[string]int
|
||||
mutex sync.Mutex
|
||||
expirationDuration time.Duration
|
||||
}
|
||||
|
||||
// Init initializes the limiter once. Repeated calls leave the first configuration unchanged.
|
||||
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
|
||||
if l.store == nil {
|
||||
l.mutex.Lock()
|
||||
if l.store == nil {
|
||||
l.store = make(map[string]*[]int64)
|
||||
l.expirationDuration = expirationDuration
|
||||
if expirationDuration > 0 {
|
||||
go l.clearExpiredItems()
|
||||
}
|
||||
}
|
||||
l.mutex.Unlock()
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
if l.store != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.store = make(map[string]*rateLimitEntry)
|
||||
l.lru = list.New()
|
||||
l.reservations = make(map[string]int)
|
||||
l.expirationDuration = expirationDuration
|
||||
if expirationDuration > 0 {
|
||||
go l.clearExpiredItems(time.NewTicker(expirationDuration).C)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *InMemoryRateLimiter) clearExpiredItems() {
|
||||
for {
|
||||
time.Sleep(l.expirationDuration)
|
||||
l.mutex.Lock()
|
||||
now := time.Now().Unix()
|
||||
for key := range l.store {
|
||||
queue := l.store[key]
|
||||
size := len(*queue)
|
||||
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
|
||||
delete(l.store, key)
|
||||
}
|
||||
}
|
||||
l.mutex.Unlock()
|
||||
// clearExpiredItems periodically removes expired entries from the LRU tail.
|
||||
func (l *InMemoryRateLimiter) clearExpiredItems(ticks <-chan time.Time) {
|
||||
for now := range ticks {
|
||||
l.deleteExpiredEntries(now)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteExpiredEntries walks only the oldest LRU entries and stops at the first active key.
|
||||
func (l *InMemoryRateLimiter) deleteExpiredEntries(now time.Time) {
|
||||
if l.expirationDuration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
for {
|
||||
oldest := l.lru.Back()
|
||||
if oldest == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entry := oldest.Value.(*rateLimitEntry)
|
||||
if now.Sub(entry.lastActive) < l.expirationDuration {
|
||||
return
|
||||
}
|
||||
|
||||
delete(l.store, entry.key)
|
||||
l.lru.Remove(oldest)
|
||||
entry.element = nil
|
||||
entry.requests.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// touchEntry returns the key's bucket, creating it on demand, and marks it as
|
||||
// the most recently active key. The caller must hold the mutex.
|
||||
func (l *InMemoryRateLimiter) touchEntry(key string, now time.Time) *rateLimitEntry {
|
||||
entry, ok := l.store[key]
|
||||
if !ok {
|
||||
entry = &rateLimitEntry{
|
||||
key: key,
|
||||
lastActive: now,
|
||||
}
|
||||
entry.element = l.lru.PushFront(entry)
|
||||
l.store[key] = entry
|
||||
return entry
|
||||
}
|
||||
entry.lastActive = now
|
||||
l.lru.MoveToFront(entry.element)
|
||||
return entry
|
||||
}
|
||||
|
||||
// Request parameter duration's unit is seconds
|
||||
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
// [old <-- new]
|
||||
queue, ok := l.store[key]
|
||||
now := time.Now().Unix()
|
||||
if ok {
|
||||
if len(*queue) < maxRequestNum {
|
||||
*queue = append(*queue, now)
|
||||
return true
|
||||
} else {
|
||||
if now-(*queue)[0] >= duration {
|
||||
*queue = (*queue)[1:]
|
||||
*queue = append(*queue, now)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s := make([]int64, 0, maxRequestNum)
|
||||
l.store[key] = &s
|
||||
*(l.store[key]) = append(*(l.store[key]), now)
|
||||
|
||||
now := time.Now()
|
||||
entry := l.touchEntry(key, now)
|
||||
entry.requests.removeExpired(now.Unix(), duration)
|
||||
|
||||
allowed := entry.requests.length < maxRequestNum
|
||||
if allowed {
|
||||
entry.requests.append(now.Unix())
|
||||
}
|
||||
return true
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// RateLimitReservation counts an active request towards admission until its
|
||||
// outcome is known. Failed requests release the slot without consuming quota.
|
||||
type RateLimitReservation struct {
|
||||
limiter *InMemoryRateLimiter
|
||||
key string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Reserve admits a request when the key's accepted requests plus its in-flight
|
||||
// reservations stay below maxRequests. It returns nil when the key is saturated.
|
||||
func (l *InMemoryRateLimiter) Reserve(key string, maxRequests int, duration int64) *RateLimitReservation {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry := l.touchEntry(key, now)
|
||||
entry.requests.removeExpired(now.Unix(), duration)
|
||||
if entry.requests.length+l.reservations[key] >= maxRequests {
|
||||
return nil
|
||||
}
|
||||
l.reservations[key]++
|
||||
return &RateLimitReservation{limiter: l, key: key}
|
||||
}
|
||||
|
||||
// Complete releases the reservation. A successful outcome records the request
|
||||
// in the key's sliding window; a failed one only frees the slot.
|
||||
func (r *RateLimitReservation) Complete(success bool) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.once.Do(func() {
|
||||
l := r.limiter
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
l.reservations[r.key]--
|
||||
if l.reservations[r.key] == 0 {
|
||||
delete(l.reservations, r.key)
|
||||
}
|
||||
if success {
|
||||
now := time.Now()
|
||||
l.touchEntry(r.key, now).requests.append(now.Unix())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInMemoryRateLimiterAllocatesTimestampsOnDemand(t *testing.T) {
|
||||
var limiter InMemoryRateLimiter
|
||||
limiter.Init(0)
|
||||
|
||||
require.True(t, limiter.Request("client", 1_000_000, 60))
|
||||
|
||||
entry := limiter.store["client"]
|
||||
require.NotNil(t, entry)
|
||||
assert.Equal(t, 1, entry.requests.length)
|
||||
assert.Same(t, entry.requests.head, entry.requests.tail)
|
||||
assert.Nil(t, entry.requests.head.next)
|
||||
|
||||
limiter.deleteExpiredEntries(time.Now().Add(time.Hour))
|
||||
assert.Contains(t, limiter.store, "client")
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterEnforcesWindowBoundary(t *testing.T) {
|
||||
var queue rateLimitQueue
|
||||
queue.append(100)
|
||||
queue.append(100)
|
||||
|
||||
queue.removeExpired(159, 60)
|
||||
assert.Equal(t, 2, queue.length)
|
||||
|
||||
queue.removeExpired(160, 60)
|
||||
assert.Zero(t, queue.length)
|
||||
assert.Nil(t, queue.head)
|
||||
assert.Nil(t, queue.tail)
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterRemovesAllExpiredRequestsPerCall(t *testing.T) {
|
||||
var queue rateLimitQueue
|
||||
for range 5 {
|
||||
queue.append(100)
|
||||
}
|
||||
queue.append(110)
|
||||
|
||||
queue.removeExpired(110, 10)
|
||||
assert.Equal(t, 1, queue.length)
|
||||
require.NotNil(t, queue.head)
|
||||
assert.EqualValues(t, 110, queue.head.timestamp)
|
||||
assert.Same(t, queue.head, queue.tail)
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterKeepsNonExpiredRequests(t *testing.T) {
|
||||
var queue rateLimitQueue
|
||||
for range 3 {
|
||||
queue.append(100)
|
||||
}
|
||||
|
||||
queue.removeExpired(109, 10)
|
||||
assert.Equal(t, 3, queue.length)
|
||||
assert.NotNil(t, queue.head)
|
||||
assert.NotNil(t, queue.tail)
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterEvictsOnlyExpiredLRUTail(t *testing.T) {
|
||||
var limiter InMemoryRateLimiter
|
||||
limiter.Init(0)
|
||||
limiter.expirationDuration = 10 * time.Second
|
||||
|
||||
require.True(t, limiter.Request("active", 1, 100))
|
||||
require.True(t, limiter.Request("idle", 1, 100))
|
||||
assert.False(t, limiter.Request("active", 1, 100))
|
||||
|
||||
assert.Equal(t, "active", limiter.lru.Front().Value.(*rateLimitEntry).key)
|
||||
assert.Equal(t, "idle", limiter.lru.Back().Value.(*rateLimitEntry).key)
|
||||
|
||||
idleEntry := limiter.store["idle"]
|
||||
now := time.Now()
|
||||
idleEntry.lastActive = now.Add(-10 * time.Second)
|
||||
limiter.store["active"].lastActive = now
|
||||
limiter.deleteExpiredEntries(now)
|
||||
|
||||
assert.NotContains(t, limiter.store, "idle")
|
||||
assert.Contains(t, limiter.store, "active")
|
||||
assert.Equal(t, 1, limiter.lru.Len())
|
||||
assert.Nil(t, idleEntry.element)
|
||||
assert.Nil(t, idleEntry.requests.head)
|
||||
assert.Nil(t, idleEntry.requests.tail)
|
||||
assert.Zero(t, idleEntry.requests.length)
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterCleanupTicksEvictExpiredKeys(t *testing.T) {
|
||||
var limiter InMemoryRateLimiter
|
||||
limiter.Init(0)
|
||||
limiter.expirationDuration = 10 * time.Second
|
||||
require.True(t, limiter.Request("idle", 1, 100))
|
||||
|
||||
now := time.Now()
|
||||
limiter.store["idle"].lastActive = now.Add(-10 * time.Second)
|
||||
ticks := make(chan time.Time, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
limiter.clearExpiredItems(ticks)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
ticks <- now
|
||||
close(ticks)
|
||||
<-done
|
||||
|
||||
assert.Empty(t, limiter.store)
|
||||
assert.Zero(t, limiter.lru.Len())
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterInitKeepsFirstConfiguration(t *testing.T) {
|
||||
var limiter InMemoryRateLimiter
|
||||
limiter.Init(time.Hour)
|
||||
limiter.Init(0)
|
||||
|
||||
assert.Equal(t, time.Hour, limiter.expirationDuration)
|
||||
require.True(t, limiter.Request("client", 1, 60))
|
||||
limiter.deleteExpiredEntries(time.Now().Add(time.Hour))
|
||||
assert.NotContains(t, limiter.store, "client")
|
||||
}
|
||||
|
||||
func TestInMemoryRateLimiterConcurrentInitializationAndRequests(t *testing.T) {
|
||||
var limiter InMemoryRateLimiter
|
||||
var allowed atomic.Int64
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for range 100 {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
limiter.Init(0)
|
||||
if limiter.Request("client", 10, 60) {
|
||||
allowed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
waitGroup.Wait()
|
||||
assert.EqualValues(t, 10, allowed.Load())
|
||||
assert.Len(t, limiter.store, 1)
|
||||
assert.Equal(t, 10, limiter.store["client"].requests.length)
|
||||
}
|
||||
@@ -24,17 +24,17 @@ func RedisKeyCacheSeconds() int {
|
||||
func InitRedisClient() (err error) {
|
||||
if os.Getenv("REDIS_CONN_STRING") == "" {
|
||||
RedisEnabled = false
|
||||
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
|
||||
SysLog(LogText("REDIS_CONN_STRING not set, Redis is not enabled"))
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("SYNC_FREQUENCY") == "" {
|
||||
SysLog("SYNC_FREQUENCY not set, use default value 60")
|
||||
SysLog(LogText("SYNC_FREQUENCY not set, use default value 60"))
|
||||
SyncFrequency = 60
|
||||
}
|
||||
SysLog("Redis is enabled")
|
||||
SysLog(LogText("Redis is enabled"))
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
|
||||
}
|
||||
opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
|
||||
RDB = redis.NewClient(opt)
|
||||
@@ -44,11 +44,11 @@ func InitRedisClient() (err error) {
|
||||
|
||||
_, err = RDB.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
FatalLog("Redis ping test failed: " + err.Error())
|
||||
FatalLog(LogText("Redis ping test failed: %s", err.Error()))
|
||||
}
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr))
|
||||
SysLog(fmt.Sprintf("Redis database: %d", opt.DB))
|
||||
SysLog(LogText("Redis connected to %s", opt.Addr))
|
||||
SysLog(LogText("Redis database: %d", opt.DB))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -56,14 +56,14 @@ func InitRedisClient() (err error) {
|
||||
func ParseRedisOption() *redis.Options {
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
func RedisSet(key string, value string, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
||||
SysLog(LogText("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Set(ctx, key, value, expiration).Err()
|
||||
@@ -71,7 +71,7 @@ func RedisSet(key string, value string, expiration time.Duration) error {
|
||||
|
||||
func RedisGet(key string) (string, error) {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis GET: key=%s", key))
|
||||
SysLog(LogText("Redis GET: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
val, err := RDB.Get(ctx, key).Result()
|
||||
@@ -90,7 +90,7 @@ func RedisGet(key string) (string, error) {
|
||||
|
||||
func RedisDel(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL: key=%s", key))
|
||||
SysLog(LogText("Redis DEL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
@@ -98,7 +98,7 @@ func RedisDel(key string) error {
|
||||
|
||||
func RedisDelKey(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
|
||||
SysLog(LogText("Redis DEL Key: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
@@ -106,7 +106,7 @@ func RedisDelKey(key string) error {
|
||||
|
||||
func RedisHSetObj(key string, obj any, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
||||
SysLog(LogText("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -160,7 +160,7 @@ func RedisHSetObj(key string, obj any, expiration time.Duration) error {
|
||||
|
||||
func RedisHGetObj(key string, obj any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key))
|
||||
SysLog(LogText("Redis HGETALL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -241,7 +241,7 @@ func RedisHGetObj(key string, obj any) error {
|
||||
// RedisIncr Add this function to handle atomic increments
|
||||
func RedisIncr(key string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta))
|
||||
SysLog(LogText("Redis INCR: key=%s, delta=%d", key, delta))
|
||||
}
|
||||
// 检查键的剩余生存时间
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
@@ -274,7 +274,7 @@ func RedisIncr(key string, delta int64) error {
|
||||
|
||||
func RedisHIncrBy(key, field string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
||||
SysLog(LogText("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
@@ -301,7 +301,7 @@ func RedisHIncrBy(key, field string, delta int64) error {
|
||||
|
||||
func RedisHSetField(key, field string, value any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
||||
SysLog(LogText("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
|
||||
@@ -135,6 +135,6 @@ func MaskEmail(email string) string {
|
||||
|
||||
// MaskSensitiveInfo moved to the conversion kit (kitutil) because the types
|
||||
// package error formatting depends on it; host callers keep this name.
|
||||
func MaskSensitiveInfo(str string) string {
|
||||
return kitutil.MaskSensitiveInfo(str)
|
||||
func MaskSensitiveInfo(str string, upstreamURLs ...string) string {
|
||||
return kitutil.MaskSensitiveInfo(str, upstreamURLs...)
|
||||
}
|
||||
@@ -50,20 +50,20 @@ func LogStartupSuccess(startTime time.Time, port string) {
|
||||
// Warn when the local HTTP compatibility mode disables cookie transport
|
||||
// security and refresh/logout Origin validation.
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[33m%s\033[0m\n", LogText("Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production."))
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m %s\n", SystemName, Version, LogText("ready in %d ms", durationMs))
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
|
||||
if !IsRunningInContainer() {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://localhost:%s/\n", LogText("Local:"), port)
|
||||
}
|
||||
|
||||
for _, ip := range networkIps {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://%s:%s/\n", LogText("Network:"), ip, port)
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -25,10 +26,36 @@ type SystemStatus struct {
|
||||
CPUUsage float64
|
||||
MemoryUsage float64
|
||||
DiskUsage float64
|
||||
SampledAt time.Time
|
||||
// 过载判定带连续性要求:单一采样尖峰不再直接熔断,需连续多次超阈值。
|
||||
CPUOverloaded bool
|
||||
MemoryOverloaded bool
|
||||
DiskOverloaded bool
|
||||
}
|
||||
|
||||
// SystemOverload 是哨兵据以拒绝请求的过载项,Error 文本带当前值与生效阈值。
|
||||
type SystemOverload struct {
|
||||
Code string
|
||||
Resource string
|
||||
Usage float64
|
||||
Threshold int
|
||||
}
|
||||
|
||||
func (o *SystemOverload) Error() string {
|
||||
return fmt.Sprintf("system %s overloaded (current: %.1f%%, threshold: %d%%)", o.Resource, o.Usage, o.Threshold)
|
||||
}
|
||||
|
||||
var latestSystemStatus atomic.Value
|
||||
|
||||
const (
|
||||
systemMonitorInterval = 5 * time.Second
|
||||
// 连续 3 次采样(约 15 秒)仍高于阈值才判定过载,
|
||||
// 任一采样回落到阈值以下即恢复,避免瞬时尖峰打满重试。
|
||||
overloadConsecutiveSamples = 3
|
||||
// 采样超过该时长未刷新(监控未运行或被阻塞)时,哨兵不据此熔断。
|
||||
systemStatusMaxAge = 30 * time.Second
|
||||
)
|
||||
|
||||
func init() {
|
||||
latestSystemStatus.Store(SystemStatus{})
|
||||
}
|
||||
@@ -36,46 +63,98 @@ func init() {
|
||||
// StartSystemMonitor 启动系统监控
|
||||
func StartSystemMonitor() {
|
||||
go func() {
|
||||
usage := newContainerUsage()
|
||||
var detector overloadDetector
|
||||
for {
|
||||
config := GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
time.Sleep(30 * time.Second)
|
||||
detector = overloadDetector{}
|
||||
latestSystemStatus.Store(SystemStatus{SampledAt: time.Now()})
|
||||
time.Sleep(systemMonitorInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
updateSystemStatus()
|
||||
time.Sleep(5 * time.Second)
|
||||
status := collectSystemStatus(usage)
|
||||
detector.mark(&status, config)
|
||||
latestSystemStatus.Store(status)
|
||||
|
||||
time.Sleep(systemMonitorInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func updateSystemStatus() {
|
||||
var status SystemStatus
|
||||
// overloadDetector 为每项资源累计连续超阈值的采样次数:连续 overloadConsecutiveSamples 次
|
||||
// 才标记过载,任一次回落立即清零并重新累计。
|
||||
type overloadDetector struct {
|
||||
cpuSamples int
|
||||
memorySamples int
|
||||
diskSamples int
|
||||
}
|
||||
|
||||
// CPU
|
||||
// 注意:cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率
|
||||
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常
|
||||
percents, err := cpu.Percent(0, false)
|
||||
if err == nil && len(percents) > 0 {
|
||||
func (d *overloadDetector) mark(status *SystemStatus, config PerformanceMonitorConfig) {
|
||||
status.CPUOverloaded = countOverloadSample(&d.cpuSamples, exceedsThreshold(status.CPUUsage, config.CPUThreshold))
|
||||
status.MemoryOverloaded = countOverloadSample(&d.memorySamples, exceedsThreshold(status.MemoryUsage, config.MemoryThreshold))
|
||||
status.DiskOverloaded = countOverloadSample(&d.diskSamples, exceedsThreshold(status.DiskUsage, config.DiskThreshold))
|
||||
}
|
||||
|
||||
func countOverloadSample(samples *int, over bool) bool {
|
||||
if !over {
|
||||
*samples = 0
|
||||
return false
|
||||
}
|
||||
*samples = min(*samples+1, overloadConsecutiveSamples)
|
||||
return *samples == overloadConsecutiveSamples
|
||||
}
|
||||
|
||||
// exceedsThreshold 判断采样值是否超过阈值;阈值 <= 0 表示不检查该项。
|
||||
func exceedsThreshold(usage float64, threshold int) bool {
|
||||
return threshold > 0 && int(usage) > threshold
|
||||
}
|
||||
|
||||
func collectSystemStatus(usage *containerUsage) SystemStatus {
|
||||
now := time.Now()
|
||||
status := SystemStatus{SampledAt: now}
|
||||
|
||||
// CPU / 内存优先按容器 cgroup 配额计算;容器口径不可用时回退宿主机口径,
|
||||
// 保持对未限制资源或被挂载为宿主 cgroup 的环境的兼容。
|
||||
if saturation, ok := usage.cpuSaturation(now); ok {
|
||||
status.CPUUsage = saturation
|
||||
} else if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
|
||||
status.CPUUsage = percents[0]
|
||||
}
|
||||
|
||||
// Memory
|
||||
memInfo, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
if containerMemory, ok := usage.memoryUsage(); ok {
|
||||
status.MemoryUsage = containerMemory
|
||||
} else if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||
status.MemoryUsage = memInfo.UsedPercent
|
||||
}
|
||||
|
||||
// Disk
|
||||
diskInfo := GetDiskSpaceInfo()
|
||||
if diskInfo.Total > 0 {
|
||||
status.DiskUsage = diskInfo.UsedPercent
|
||||
}
|
||||
|
||||
latestSystemStatus.Store(status)
|
||||
return status
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取当前系统状态
|
||||
func GetSystemStatus() SystemStatus {
|
||||
return latestSystemStatus.Load().(SystemStatus)
|
||||
}
|
||||
|
||||
// CheckSystemOverload 返回哨兵应据以熔断的过载项。监控关闭、采样缺失或已超过
|
||||
// systemStatusMaxAge 时返回 nil。过载标记来自连续采样,阈值按当前配置复核,
|
||||
// 运行时关闭或调高阈值后立即放行,不必等待下一次采样。
|
||||
func CheckSystemOverload(status SystemStatus, config PerformanceMonitorConfig, now time.Time) *SystemOverload {
|
||||
if !config.Enabled || status.SampledAt.IsZero() || now.Sub(status.SampledAt) > systemStatusMaxAge {
|
||||
return nil
|
||||
}
|
||||
if status.CPUOverloaded && exceedsThreshold(status.CPUUsage, config.CPUThreshold) {
|
||||
return &SystemOverload{Code: "system_cpu_overloaded", Resource: "cpu", Usage: status.CPUUsage, Threshold: config.CPUThreshold}
|
||||
}
|
||||
if status.MemoryOverloaded && exceedsThreshold(status.MemoryUsage, config.MemoryThreshold) {
|
||||
return &SystemOverload{Code: "system_memory_overloaded", Resource: "memory", Usage: status.MemoryUsage, Threshold: config.MemoryThreshold}
|
||||
}
|
||||
if status.DiskOverloaded && exceedsThreshold(status.DiskUsage, config.DiskThreshold) {
|
||||
return &SystemOverload{Code: "system_disk_overloaded", Resource: "disk", Usage: status.DiskUsage, Threshold: config.DiskThreshold}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestContainerUsage 在临时目录中构造 cgroup 文件,避免依赖运行环境的真实 cgroup。
|
||||
func newTestContainerUsage(t *testing.T, schedulableCPUs float64, files map[string]string) *containerUsage {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, content := range files {
|
||||
writeTestCgroupFile(t, filepath.Join(root, name), content)
|
||||
}
|
||||
return &containerUsage{cgroupRoot: root, schedulableCPUs: schedulableCPUs}
|
||||
}
|
||||
|
||||
func writeTestCgroupFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
}
|
||||
|
||||
func TestContainerCPUSaturation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schedulableCPUs float64
|
||||
files map[string]string
|
||||
usageFile string
|
||||
usageAfter string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "cgroup v2 quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\nuser_usec 900000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 6000000\n", // 5 秒用 1 核,配额 2 核
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
name: "fractional quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{"cpu.max": "50000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 3500000\n", // 5 秒用 0.5 核,配额 0.5 核
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "quota above schedulable CPUs",
|
||||
schedulableCPUs: 1,
|
||||
files: map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 6000000\n", // cpuset 只有 1 核,已打满
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "cgroup v1 quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{
|
||||
"cpu/cpu.cfs_quota_us": "200000\n",
|
||||
"cpu/cpu.cfs_period_us": "100000\n",
|
||||
"cpuacct/cpuacct.usage": "1000000000\n",
|
||||
},
|
||||
usageFile: "cpuacct/cpuacct.usage",
|
||||
usageAfter: "11000000000\n", // 纳秒:5 秒用 2 核
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, tt.schedulableCPUs, tt.files)
|
||||
base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
_, ok := usage.cpuSaturation(base)
|
||||
assert.False(t, ok, "首次采样只建立基线")
|
||||
|
||||
writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, tt.usageFile), tt.usageAfter)
|
||||
got, ok := usage.cpuSaturation(base.Add(5 * time.Second))
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, tt.want, got, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerCPUSaturationFallsBackWithoutQuota(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
}{
|
||||
{name: "cgroup v2 unlimited", files: map[string]string{"cpu.max": "max 100000\n", "cpu.stat": "usage_usec 1000000\n"}},
|
||||
{name: "cgroup v1 unlimited", files: map[string]string{"cpu/cpu.cfs_quota_us": "-1\n", "cpu/cpu.cfs_period_us": "100000\n", "cpuacct/cpuacct.usage": "1000\n"}},
|
||||
{name: "no cgroup files"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, 4, tt.files)
|
||||
base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
usage.cpuSaturation(base)
|
||||
_, ok := usage.cpuSaturation(base.Add(5 * time.Second))
|
||||
assert.False(t, ok, "没有配额时交由调用方回退宿主机口径")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerMemoryUsageUsesWorkingSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "cgroup v2 excludes inactive file cache",
|
||||
files: map[string]string{
|
||||
"memory.current": "266338304\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "anon 4435968\nfile 253362176\ninactive_file 251662336\n",
|
||||
},
|
||||
want: 5.4672,
|
||||
},
|
||||
{
|
||||
name: "cgroup v2 high working set keeps protection",
|
||||
files: map[string]string{
|
||||
"memory.current": "255852544\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "anon 254803968\ninactive_file 1048576\n",
|
||||
},
|
||||
want: 94.9219,
|
||||
},
|
||||
{
|
||||
name: "cgroup v1 uses hierarchical inactive file cache",
|
||||
files: map[string]string{
|
||||
"memory/memory.usage_in_bytes": "805306368\n",
|
||||
"memory/memory.limit_in_bytes": "1610612736\n",
|
||||
"memory/memory.stat": "inactive_file 999\ntotal_inactive_file 268435456\n",
|
||||
},
|
||||
want: 33.3333,
|
||||
},
|
||||
{
|
||||
name: "cache larger than usage does not underflow",
|
||||
files: map[string]string{
|
||||
"memory.current": "1000\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "inactive_file 2000\n",
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := newTestContainerUsage(t, 4, tt.files).memoryUsage()
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, tt.want, got, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerMemoryUsageFallsBackWhenUnavailable(t *testing.T) {
|
||||
limited := map[string]string{"memory.current": "266338304\n", "memory.max": "268435456\n"}
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
stat string
|
||||
}{
|
||||
{name: "cgroup v2 unlimited", files: map[string]string{"memory.current": "1000\n", "memory.max": "max\n"}, stat: "inactive_file 0\n"},
|
||||
{name: "cgroup v1 unlimited", files: map[string]string{
|
||||
"memory/memory.usage_in_bytes": "1000\n",
|
||||
"memory/memory.limit_in_bytes": "9223372036854771712\n",
|
||||
"memory/memory.stat": "total_inactive_file 0\n",
|
||||
}},
|
||||
{name: "memory.stat missing", files: limited},
|
||||
{name: "inactive file field missing", files: limited, stat: "anon 4435968\n"},
|
||||
{name: "inactive file field corrupt", files: limited, stat: "inactive_file abc\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, 4, tt.files)
|
||||
if tt.stat != "" {
|
||||
writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, "memory.stat"), tt.stat)
|
||||
}
|
||||
_, ok := usage.memoryUsage()
|
||||
assert.False(t, ok, "拿不到可信工作集时交由调用方回退宿主机口径")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemOverloadRequiresConsecutiveSamples(t *testing.T) {
|
||||
config := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
|
||||
now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
cpuSamples := []struct {
|
||||
usage float64
|
||||
wantOverload bool
|
||||
}{
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: true},
|
||||
{usage: 95, wantOverload: true},
|
||||
{usage: 50, wantOverload: false}, // 回落即恢复
|
||||
{usage: 95, wantOverload: false}, // 重新累计
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: true},
|
||||
}
|
||||
|
||||
var detector overloadDetector
|
||||
for i, sample := range cpuSamples {
|
||||
status := SystemStatus{CPUUsage: sample.usage, SampledAt: now}
|
||||
detector.mark(&status, config)
|
||||
overload := CheckSystemOverload(status, config, now)
|
||||
if !sample.wantOverload {
|
||||
assert.Nil(t, overload, "sample %d", i+1)
|
||||
continue
|
||||
}
|
||||
require.NotNil(t, overload, "sample %d", i+1)
|
||||
assert.Equal(t, "system_cpu_overloaded", overload.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSystemOverload(t *testing.T) {
|
||||
now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
enabled := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
|
||||
cpuOverloaded := SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now}
|
||||
tests := []struct {
|
||||
name string
|
||||
status SystemStatus
|
||||
config PerformanceMonitorConfig
|
||||
wantCode string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "fresh cpu overload",
|
||||
status: cpuOverloaded,
|
||||
config: enabled,
|
||||
wantCode: "system_cpu_overloaded",
|
||||
wantMessage: "system cpu overloaded (current: 99.5%, threshold: 90%)",
|
||||
},
|
||||
{
|
||||
name: "fresh memory overload",
|
||||
status: SystemStatus{MemoryUsage: 96.2, MemoryOverloaded: true, SampledAt: now},
|
||||
config: enabled,
|
||||
wantCode: "system_memory_overloaded",
|
||||
wantMessage: "system memory overloaded (current: 96.2%, threshold: 90%)",
|
||||
},
|
||||
{
|
||||
name: "fresh disk overload",
|
||||
status: SystemStatus{DiskUsage: 97, DiskOverloaded: true, SampledAt: now},
|
||||
config: enabled,
|
||||
wantCode: "system_disk_overloaded",
|
||||
wantMessage: "system disk overloaded (current: 97.0%, threshold: 95%)",
|
||||
},
|
||||
{
|
||||
name: "lowered threshold reports the active threshold",
|
||||
status: cpuOverloaded,
|
||||
config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 80},
|
||||
wantCode: "system_cpu_overloaded",
|
||||
wantMessage: "system cpu overloaded (current: 99.5%, threshold: 80%)",
|
||||
},
|
||||
{name: "monitor disabled", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: false, CPUThreshold: 90}},
|
||||
{name: "cpu threshold disabled at runtime", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 0}},
|
||||
{name: "cpu threshold raised above usage", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 100}},
|
||||
{name: "stale sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now.Add(-31 * time.Second)}, config: enabled},
|
||||
{name: "missing sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true}, config: enabled},
|
||||
{name: "usage above threshold without consecutive samples", status: SystemStatus{CPUUsage: 99.5, SampledAt: now}, config: enabled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
overload := CheckSystemOverload(tt.status, tt.config, now)
|
||||
if tt.wantCode == "" {
|
||||
assert.Nil(t, overload)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, overload)
|
||||
assert.Equal(t, tt.wantCode, overload.Code)
|
||||
assert.Equal(t, tt.wantMessage, overload.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func TopupGroupRatio2JSONString() string {
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
jsonBytes, err := json.Marshal(topupGroupRatio)
|
||||
if err != nil {
|
||||
SysError("error marshalling topup group ratio: " + err.Error())
|
||||
SysError(LogText("error marshalling topup group ratio: %s", err.Error()))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func GetTopupGroupRatio(name string) float64 {
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
ratio, ok := topupGroupRatio[name]
|
||||
if !ok {
|
||||
SysError("topup group ratio not found: " + name)
|
||||
SysError(LogText("topup group ratio not found: %s", name))
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -130,12 +131,12 @@ func ValidateNumericCode(code string) (string, error) {
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 6 {
|
||||
return "", fmt.Errorf("验证码必须是6位数字")
|
||||
return "", errors.New("the verification code must be 6 digits")
|
||||
}
|
||||
|
||||
// 检查是否为纯数字
|
||||
if _, err := strconv.Atoi(code); err != nil {
|
||||
return "", fmt.Errorf("验证码只能包含数字")
|
||||
return "", errors.New("the verification code must contain only digits")
|
||||
}
|
||||
|
||||
return code, nil
|
||||
|
||||
@@ -163,31 +163,6 @@ func Bytes2Size(num int64) string {
|
||||
return numStr + " " + unit
|
||||
}
|
||||
|
||||
func Seconds2Time(num int) (time string) {
|
||||
if num/31104000 > 0 {
|
||||
time += strconv.Itoa(num/31104000) + " 年 "
|
||||
num %= 31104000
|
||||
}
|
||||
if num/2592000 > 0 {
|
||||
time += strconv.Itoa(num/2592000) + " 个月 "
|
||||
num %= 2592000
|
||||
}
|
||||
if num/86400 > 0 {
|
||||
time += strconv.Itoa(num/86400) + " 天 "
|
||||
num %= 86400
|
||||
}
|
||||
if num/3600 > 0 {
|
||||
time += strconv.Itoa(num/3600) + " 小时 "
|
||||
num %= 3600
|
||||
}
|
||||
if num/60 > 0 {
|
||||
time += strconv.Itoa(num/60) + " 分钟 "
|
||||
num %= 60
|
||||
}
|
||||
time += strconv.Itoa(num) + " 秒"
|
||||
return
|
||||
}
|
||||
|
||||
func Interface2String(inter any) string {
|
||||
switch inter.(type) {
|
||||
case string:
|
||||
|
||||
@@ -59,10 +59,14 @@ const (
|
||||
ChannelTypeSub2API = 59
|
||||
ChannelTypeNewAPI = 60
|
||||
ChannelTypeTaskPlugin = 61
|
||||
ChannelTypeVLLM = 62
|
||||
ChannelTypeSGLang = 63
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
|
||||
// ChannelBaseURLs 保存各渠道类型的内置默认 Base URL。
|
||||
// 非空值会通过 /api/channel/default_base_urls 下发到前端,作为渠道表单的 API 地址占位提示。
|
||||
var ChannelBaseURLs = []string{
|
||||
"", // 0
|
||||
"https://api.openai.com", // 1
|
||||
@@ -126,6 +130,8 @@ var ChannelBaseURLs = []string{
|
||||
"", //59
|
||||
"", //60
|
||||
"", //61
|
||||
"", //62
|
||||
"", //63
|
||||
}
|
||||
|
||||
func GetChannelBaseURL(channelType int) string {
|
||||
@@ -186,7 +192,7 @@ var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeJimeng: "Jimeng",
|
||||
ChannelTypeVidu: "Vidu",
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
ChannelTypeDoubaoVideo: "Doubao",
|
||||
ChannelTypeSora: "Sora",
|
||||
ChannelTypeReplicate: "Replicate",
|
||||
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
|
||||
@@ -194,6 +200,8 @@ var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeSub2API: "Sub2API",
|
||||
ChannelTypeNewAPI: "New API",
|
||||
ChannelTypeTaskPlugin: "Task Plugin",
|
||||
ChannelTypeVLLM: "vLLM",
|
||||
ChannelTypeSGLang: "SGLang",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
@@ -226,3 +234,13 @@ var ChannelSpecialBases = map[string]ChannelSpecialBase{
|
||||
OpenAIBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
}
|
||||
|
||||
// IsAdvancedCustomChannel includes named channels backed by route presets.
|
||||
func IsAdvancedCustomChannel(channelType int) bool {
|
||||
switch channelType {
|
||||
case ChannelTypeAdvancedCustom, ChannelTypeVLLM, ChannelTypeSGLang:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,20 @@ const (
|
||||
ContextKeyRequestStartTime ContextKey = "request_start_time"
|
||||
|
||||
/* token related keys */
|
||||
ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota"
|
||||
ContextKeyTokenKey ContextKey = "token_key"
|
||||
ContextKeyTokenId ContextKey = "token_id"
|
||||
ContextKeyTokenGroup ContextKey = "token_group"
|
||||
ContextKeyOriginTasks ContextKey = "origin_tasks"
|
||||
ContextKeyChannelConstraints ContextKey = "channel_constraints"
|
||||
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
|
||||
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
|
||||
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
|
||||
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
|
||||
ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota"
|
||||
ContextKeyTokenKey ContextKey = "token_key"
|
||||
ContextKeyTokenId ContextKey = "token_id"
|
||||
ContextKeyTokenGroup ContextKey = "token_group"
|
||||
ContextKeyOriginTasks ContextKey = "origin_tasks"
|
||||
ContextKeyChannelConstraints ContextKey = "channel_constraints"
|
||||
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
|
||||
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
|
||||
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
|
||||
ContextKeyTokenAstraDetectionDisabled ContextKey = "token_astra_detection_disabled"
|
||||
ContextKeyTokenAstraAutoSwitch ContextKey = "token_astra_auto_switch"
|
||||
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
|
||||
ContextKeyTokenGroupPool ContextKey = "token_group_pool"
|
||||
ContextKeyTokenFailedGroups ContextKey = "token_failed_groups"
|
||||
|
||||
/* channel related keys */
|
||||
ContextKeyChannelId ContextKey = "channel_id"
|
||||
@@ -40,6 +44,11 @@ const (
|
||||
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
|
||||
ContextKeyChannelKey ContextKey = "channel_key"
|
||||
|
||||
// ContextKeyUpstreamRequestOrigin is the scheme and host of the last upstream
|
||||
// request, which differs from the base URL for absolute Advanced Custom routes
|
||||
// and for adaptors that build official endpoints themselves.
|
||||
ContextKeyUpstreamRequestOrigin ContextKey = "upstream_request_origin"
|
||||
|
||||
ContextKeyAutoGroup ContextKey = "auto_group"
|
||||
ContextKeyAutoGroupIndex ContextKey = "auto_group_index"
|
||||
ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index"
|
||||
@@ -66,8 +75,9 @@ const (
|
||||
ContextKeyAdminRejectReason ContextKey = "admin_reject_reason"
|
||||
|
||||
// ContextKeyLanguage stores the user's language preference for i18n
|
||||
ContextKeyLanguage ContextKey = "language"
|
||||
ContextKeyIsStream ContextKey = "is_stream"
|
||||
ContextKeyLanguage ContextKey = "language"
|
||||
ContextKeyIsStream ContextKey = "is_stream"
|
||||
ContextKeyResponseStreamStatus ContextKey = "response_stream_status"
|
||||
|
||||
// ContextKeyAuditLogged marks that the current request has already recorded
|
||||
// a manage/operation audit log inside the handler. When set, the admin-audit
|
||||
|
||||
@@ -11,6 +11,8 @@ const (
|
||||
EndpointTypeOpenAIResponse = types.EndpointTypeOpenAIResponse
|
||||
EndpointTypeOpenAIResponseCompact = types.EndpointTypeOpenAIResponseCompact
|
||||
EndpointTypeOpenAIAlphaSearch = types.EndpointTypeOpenAIAlphaSearch
|
||||
EndpointTypeDeepSeekToolsSearch = types.EndpointTypeDeepSeekToolsSearch
|
||||
EndpointTypeDeepSeekToolsFetch = types.EndpointTypeDeepSeekToolsFetch
|
||||
EndpointTypeAnthropic = types.EndpointTypeAnthropic
|
||||
EndpointTypeGemini = types.EndpointTypeGemini
|
||||
EndpointTypeJinaRerank = types.EndpointTypeJinaRerank
|
||||
@@ -18,3 +20,10 @@ const (
|
||||
EndpointTypeEmbeddings = types.EndpointTypeEmbeddings
|
||||
EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo
|
||||
)
|
||||
|
||||
// Model names the distributor assigns to /v1/tools/search and /v1/tools/fetch,
|
||||
// whose bodies carry no model. Channels serve the endpoints by listing them.
|
||||
const (
|
||||
DeepSeekSearchModel = "deepseek-search"
|
||||
DeepSeekFetchModel = "deepseek-fetch"
|
||||
)
|
||||
@@ -1,64 +1,343 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetAccessTokenStatus(c *gin.Context) {
|
||||
status, err := model.GetUserAccessTokenStatus(c.GetInt("id"))
|
||||
// accessTokenMinLifetimeSecs is the shortest expiry a new access token may have.
|
||||
const accessTokenMinLifetimeSecs = 60 * 60
|
||||
|
||||
type accessTokenItem struct {
|
||||
model.UserAccessToken
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type accessTokenCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// accessTokenUpdateRequest renames a token; a present scopes field also
|
||||
// replaces its grant.
|
||||
type accessTokenUpdateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func newAccessTokenItem(token *model.UserAccessToken) accessTokenItem {
|
||||
scopes := token.GetScopes()
|
||||
if scopes == nil {
|
||||
scopes = []string{}
|
||||
}
|
||||
return accessTokenItem{UserAccessToken: *token, Scopes: scopes}
|
||||
}
|
||||
|
||||
// writeAccessTokenError responds with a stable code and a web console message.
|
||||
func writeAccessTokenError(c *gin.Context, status int, code, key string, params ...map[string]any) {
|
||||
common.ApiErrorStatus(c, status, common.NewMessage(key, params...), gin.H{"code": code})
|
||||
}
|
||||
|
||||
// normalizeAccessTokenName returns the trimmed name, or false when it is empty
|
||||
// or longer than the 64-character column.
|
||||
func normalizeAccessTokenName(name string) (string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
count := utf8.RuneCountInString(name)
|
||||
return name, count > 0 && count <= 64
|
||||
}
|
||||
|
||||
func ListAccessTokens(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tokens, err := model.ListUserAccessTokens(identity.UserID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, status)
|
||||
items := make([]accessTokenItem, 0, len(tokens))
|
||||
for i := range tokens {
|
||||
items = append(items, newAccessTokenItem(&tokens[i]))
|
||||
}
|
||||
var legacy gin.H
|
||||
if !model.LegacyAccessTokensRetired(common.GetTimestamp()) {
|
||||
status, err := model.GetUserAccessTokenStatus(identity.UserID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if status.Exists {
|
||||
legacy = gin.H{
|
||||
"token_hint": status.TokenHint,
|
||||
"token_ref": status.TokenRef,
|
||||
"created_at": status.CreatedAt,
|
||||
"last_used_at": status.LastUsedAt,
|
||||
"last_used_ip": status.LastUsedIp,
|
||||
"retire_at": model.LegacyAccessTokenRetireAt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": items, "legacy": legacy})
|
||||
}
|
||||
|
||||
func GenerateAccessToken(c *gin.Context) {
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil {
|
||||
func GetAccessTokenCatalog(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4))
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"groups": service.AccessTokenCatalog(identity.UserID, c.GetInt("role")),
|
||||
"max_tokens": service.AccessTokenMaxPerUser,
|
||||
"default_expiry_days": service.AccessTokenDefaultExpiryDays,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccessTokenScopes returns the unfiltered scope labels used to describe
|
||||
// stored grants, such as those in audit records.
|
||||
func GetAccessTokenScopes(c *gin.Context) {
|
||||
if _, ok := requireBrowserSession(c); !ok {
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"resources": service.AccessTokenScopeDictionary()})
|
||||
}
|
||||
|
||||
// CreateAccessToken validates the whole request before consuming the proof, so
|
||||
// a rejected grant never burns the user's verification.
|
||||
func CreateAccessToken(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req accessTokenCreateRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
|
||||
return
|
||||
}
|
||||
name, ok := normalizeAccessTokenName(req.Name)
|
||||
if !ok {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
|
||||
return
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
if req.ExpiresAt != 0 && req.ExpiresAt < now+accessTokenMinLifetimeSecs {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_EXPIRY_INVALID", "Access token expiry must be at least one hour from now.")
|
||||
return
|
||||
}
|
||||
scopes, err := service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
|
||||
return
|
||||
case err != nil:
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
|
||||
return
|
||||
}
|
||||
proofContext, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: req.ExpiresAt})
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
var existing int64
|
||||
if err := model.DB.Model(&model.User{}).Where("access_token = ?", key).Count(&existing).Error; err != nil {
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: proofContext}) == nil {
|
||||
return
|
||||
}
|
||||
suffix, err := common.GenerateRandomCharsKey(43)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if existing != 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
|
||||
return
|
||||
raw := model.AccessTokenPrefix + suffix
|
||||
token := &model.UserAccessToken{
|
||||
Name: name,
|
||||
TokenHash: model.AccessTokenFingerprint(raw),
|
||||
TokenHint: model.AccessTokenHint(raw),
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := model.UpdateUserAccessToken(id, key); err != nil {
|
||||
if err := token.SetScopes(scopes); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
recordUserSecurityAudit(c, id, "access_token.generate", map[string]any{"token_ref": model.AccessTokenFingerprint(key)})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": key})
|
||||
if err := model.CreateUserAccessToken(identity.UserID, token, service.AccessTokenMaxPerUser); err != nil {
|
||||
if errors.Is(err, model.ErrAccessTokenLimit) {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", "You can create at most {{count}} access tokens", map[string]any{"count": service.AccessTokenMaxPerUser})
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
recordUserSecurityAudit(c, identity.UserID, "access_token.generate", map[string]any{
|
||||
"token_id": token.Id,
|
||||
"name": token.Name,
|
||||
"scopes": scopes,
|
||||
"expires_at": token.ExpiresAt,
|
||||
"token_ref": token.TokenHash,
|
||||
})
|
||||
common.ApiSuccess(c, gin.H{"token": raw, "item": newAccessTokenItem(token)})
|
||||
}
|
||||
|
||||
func RevokeAccessToken(c *gin.Context) {
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil {
|
||||
// UpdateAccessToken renames a token without verification. Changing the grant
|
||||
// is checked in full before the proof is consumed, like creation.
|
||||
func UpdateAccessToken(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
var req accessTokenUpdateRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
|
||||
return
|
||||
}
|
||||
name, ok := normalizeAccessTokenName(req.Name)
|
||||
if !ok {
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
|
||||
return
|
||||
}
|
||||
var scopes []string
|
||||
var previous *model.UserAccessToken
|
||||
if req.Scopes != nil {
|
||||
previous, err = model.GetUserAccessToken(identity.UserID, id)
|
||||
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
scopes, err = service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
|
||||
return
|
||||
case err != nil:
|
||||
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
|
||||
return
|
||||
}
|
||||
proofContext, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: id, Scopes: scopes})
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: proofContext}) == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
token, err := model.UpdateUserAccessToken(identity.UserID, id, name, scopes)
|
||||
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if previous == nil {
|
||||
recordUserSecurityAudit(c, identity.UserID, "access_token.rename", map[string]any{"token_id": token.Id, "name": token.Name})
|
||||
common.ApiSuccess(c, newAccessTokenItem(token))
|
||||
return
|
||||
}
|
||||
recordUserSecurityAudit(c, identity.UserID, "access_token.update", map[string]any{
|
||||
"token_id": token.Id,
|
||||
"name": token.Name,
|
||||
"scopes": scopes,
|
||||
"previous_scopes": previous.GetScopes(),
|
||||
"token_ref": token.TokenHash,
|
||||
})
|
||||
common.ApiSuccess(c, newAccessTokenItem(token))
|
||||
}
|
||||
|
||||
// DeleteAccessToken checks ownership before consuming the proof, so probing
|
||||
// other users' token IDs never burns a verification.
|
||||
func DeleteAccessToken(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
if _, err := model.GetUserAccessToken(identity.UserID, id); err != nil {
|
||||
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{TokenID: id})
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
|
||||
return
|
||||
}
|
||||
token, err := model.DeleteUserAccessToken(identity.UserID, id)
|
||||
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"token_id": token.Id, "name": token.Name, "token_ref": token.TokenHash})
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// RevokeLegacyAccessToken removes the pre-scope users.access_token credential.
|
||||
// After the transition deadline the legacy token no longer exists for callers.
|
||||
func RevokeLegacyAccessToken(c *gin.Context) {
|
||||
identity, ok := requireBrowserSession(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if model.LegacyAccessTokensRetired(common.GetTimestamp()) {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
status, err := model.GetUserAccessTokenStatus(identity.UserID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if !status.Exists {
|
||||
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
||||
return
|
||||
}
|
||||
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{Legacy: true})
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
|
||||
return
|
||||
}
|
||||
ref, err := model.RevokeUserAccessToken(identity.UserID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if ref != "" {
|
||||
recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]any{"token_ref": ref})
|
||||
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"legacy": true, "token_ref": ref})
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"gorm.io/driver/clickhouse"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -37,9 +38,12 @@ func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
|
||||
previousRedis := common.RedisEnabled
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}))
|
||||
require.NoError(t, i18n.Init())
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.UserAccessToken{}, &model.Option{},
|
||||
&model.AuthFlow{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.UserOAuthBinding{}))
|
||||
model.DB, model.LOG_DB = db, db
|
||||
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
|
||||
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
||||
common.RedisEnabled = false
|
||||
previousMaster := common.IsMasterNode
|
||||
common.IsMasterNode = true
|
||||
@@ -66,56 +70,447 @@ func auditRequest(router http.Handler, method, path, token string) *httptest.Res
|
||||
return response
|
||||
}
|
||||
|
||||
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
|
||||
user, old := setupAccessTokenAudit(t)
|
||||
// newAccessTokenTestRouter mirrors the production paths, so the access token
|
||||
// route rules keyed by gin full path apply unchanged.
|
||||
func newAccessTokenTestRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
|
||||
router.GET("/api/user/token/status", middleware.UserAuth(), GetAccessTokenStatus)
|
||||
router.GET("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
|
||||
router.POST("/api/user/token", middleware.UserAuth(), GenerateAccessToken)
|
||||
router.DELETE("/api/user/token", middleware.UserAuth(), RevokeAccessToken)
|
||||
// Rotate during the handler, after authentication has captured the old PAT.
|
||||
router.POST("/rotate-in-flight", middleware.UserAuth(), func(c *gin.Context) {
|
||||
require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token"))
|
||||
c.JSON(200, gin.H{"success": true})
|
||||
})
|
||||
status, err := model.GetUserAccessTokenStatus(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, status.Exists)
|
||||
assert.Nil(t, status.CreatedAt)
|
||||
assert.Nil(t, status.LastUsedAt)
|
||||
assert.NotContains(t, auditRequest(router, "GET", "/api/user/token/status", old).Body.String(), old)
|
||||
require.Equal(t, 200, auditRequest(router, "POST", "/rotate-in-flight", old).Code)
|
||||
status, err = model.GetUserAccessTokenStatus(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AccessTokenFingerprint("new-token"), status.TokenRef)
|
||||
assert.NotNil(t, status.CreatedAt)
|
||||
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used")
|
||||
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code)
|
||||
for _, method := range []string{"POST", "GET", "DELETE"} {
|
||||
response := auditRequest(router, method, "/api/user/token", "new-token")
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
|
||||
api := router.Group("/api")
|
||||
api.GET("/verify/methods", middleware.UserAuth(), GetVerificationMethods)
|
||||
api.POST("/verify", middleware.UserAuth(), UniversalVerify)
|
||||
userRoute := api.Group("/user")
|
||||
selfRoute := userRoute.Group("/", middleware.UserAuth())
|
||||
selfRoute.GET("/self", func(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": c.GetInt("id")}) })
|
||||
selfRoute.POST("/2fa/setup", Setup2FA)
|
||||
selfRoute.POST("/2fa/disable", Disable2FA)
|
||||
tokenRoute := selfRoute.Group("/access_tokens")
|
||||
tokenRoute.GET("", ListAccessTokens)
|
||||
tokenRoute.GET("/catalog", GetAccessTokenCatalog)
|
||||
tokenRoute.GET("/scopes", GetAccessTokenScopes)
|
||||
tokenRoute.POST("", CreateAccessToken)
|
||||
tokenRoute.PATCH("/:id", UpdateAccessToken)
|
||||
tokenRoute.DELETE("/:id", DeleteAccessToken)
|
||||
tokenRoute.DELETE("/legacy", RevokeLegacyAccessToken)
|
||||
adminRoute := userRoute.Group("/", middleware.AdminAuth())
|
||||
adminRoute.DELETE("/:id", DeleteUser)
|
||||
return router
|
||||
}
|
||||
|
||||
func accessTokenRequest(router http.Handler, method, path, credential, proof, body string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if proof != "" {
|
||||
request.Header.Set("X-Security-Proof", proof)
|
||||
}
|
||||
_, err = model.RevokeUserAccessToken(user.Id)
|
||||
request.RemoteAddr = "192.0.2.8:4567"
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func createScopedAccessToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
|
||||
t.Helper()
|
||||
suffix, err := common.GenerateRandomCharsKey(43)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
|
||||
ref, err := model.RevokeUserAccessToken(user.Id)
|
||||
raw := model.AccessTokenPrefix + suffix
|
||||
token := &model.UserAccessToken{Name: "test token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
|
||||
require.NoError(t, token.SetScopes(scopes))
|
||||
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
|
||||
return raw, token
|
||||
}
|
||||
|
||||
// createAccessTokenTestSession returns a browser session identity and the
|
||||
// dashboard JWT that carries it.
|
||||
func createAccessTokenTestSession(t *testing.T, userID int, sid string) (service.AuthIdentity, string) {
|
||||
t.Helper()
|
||||
now := time.Now().Unix()
|
||||
require.NoError(t, model.CreateUserSession(&model.UserSession{SID: sid, UserID: userID, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}))
|
||||
identity := service.AuthIdentity{UserID: userID, SessionID: sid, UserAuthVersion: 1, SessionVersion: 1}
|
||||
jwt, _, err := service.IssueAccessToken(identity)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ref, "repeated revocation is idempotent")
|
||||
status, err = model.GetUserAccessTokenStatus(user.Id)
|
||||
return identity, jwt
|
||||
}
|
||||
|
||||
func issueAccessTokenGenerateProof(t *testing.T, identity service.AuthIdentity, expiresAt int64, scopes ...string) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: expiresAt})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.Exists)
|
||||
assert.Nil(t, status.CreatedAt)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: context}, "password")
|
||||
}
|
||||
|
||||
func issueAccessTokenRevokeProof(t *testing.T, identity service.AuthIdentity, target service.AccessTokenRevokeContext) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(target)
|
||||
require.NoError(t, err)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: context}, "password")
|
||||
}
|
||||
|
||||
func issueAccessTokenUpdateProof(t *testing.T, identity service.AuthIdentity, tokenID int, scopes ...string) string {
|
||||
t.Helper()
|
||||
context, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: tokenID, Scopes: scopes})
|
||||
require.NoError(t, err)
|
||||
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: context}, "password")
|
||||
}
|
||||
|
||||
// setLegacyAccessTokenRetireAt moves the stored transition deadline the way an
|
||||
// operator restoring the option row would, then reloads it.
|
||||
func setLegacyAccessTokenRetireAt(t *testing.T, retireAt int64) {
|
||||
t.Helper()
|
||||
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(retireAt)).Error)
|
||||
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
|
||||
require.Equal(t, retireAt, model.LegacyAccessTokenRetireAt())
|
||||
}
|
||||
|
||||
type accessTokenListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenRef string `json:"token_ref"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"items"`
|
||||
Legacy *struct {
|
||||
TokenRef string `json:"token_ref"`
|
||||
RetireAt int64 `json:"retire_at"`
|
||||
} `json:"legacy"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
|
||||
user, legacy := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "lifecycle-session")
|
||||
scoped, scopedToken := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", scopedToken.Id)
|
||||
for _, credential := range []string{scoped, legacy} {
|
||||
for _, endpoint := range []struct{ method, path string }{
|
||||
{"GET", "/api/user/access_tokens"}, {"GET", "/api/user/access_tokens/catalog"}, {"GET", "/api/user/access_tokens/scopes"}, {"POST", "/api/user/access_tokens"},
|
||||
{"PATCH", tokenPath}, {"DELETE", tokenPath}, {"DELETE", "/api/user/access_tokens/legacy"},
|
||||
} {
|
||||
response := accessTokenRequest(router, endpoint.method, endpoint.path, credential, "", `{"name":"renamed","scopes":["profile:read"],"expires_at":0}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code, endpoint.method+" "+endpoint.path)
|
||||
assert.Contains(t, response.Body.String(), `"code":"AUTH_SESSION_REQUIRED"`)
|
||||
}
|
||||
}
|
||||
tokens, err := model.ListUserAccessTokens(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
assert.Equal(t, "test token", tokens[0].Name, "an access token cannot manage access tokens")
|
||||
|
||||
response := accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
for _, secret := range []string{scoped, legacy} {
|
||||
assert.NotContains(t, response.Body.String(), secret)
|
||||
}
|
||||
var listed accessTokenListResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
require.Len(t, listed.Data.Items, 1)
|
||||
assert.Equal(t, scopedToken.TokenHash, listed.Data.Items[0].TokenRef)
|
||||
assert.Equal(t, []string{"profile:read"}, listed.Data.Items[0].Scopes)
|
||||
require.NotNil(t, listed.Data.Legacy)
|
||||
assert.Equal(t, model.AccessTokenFingerprint(legacy), listed.Data.Legacy.TokenRef)
|
||||
assert.Equal(t, model.LegacyAccessTokenRetireAt(), listed.Data.Legacy.RetireAt)
|
||||
|
||||
// A token id only resolves among the caller's own tokens.
|
||||
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
otherRaw, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
|
||||
otherProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: otherToken.Id})
|
||||
response = accessTokenRequest(router, "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), browser, otherProof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", otherRaw, "", "").Code)
|
||||
|
||||
// A revoke proof names one target and is not spent on another one.
|
||||
proof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: scopedToken.Id})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, proof, "")
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", scoped, "", "").Code)
|
||||
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
|
||||
|
||||
legacyProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
listed = accessTokenListResponse{}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
assert.Empty(t, listed.Data.Items)
|
||||
assert.Nil(t, listed.Data.Legacy)
|
||||
|
||||
// After the deadline a stored legacy value is inert: it is neither listed,
|
||||
// revocable nor accepted, and the column itself is left untouched.
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", legacy).Error)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
|
||||
retireAt := model.LegacyAccessTokenRetireAt()
|
||||
setLegacyAccessTokenRetireAt(t, time.Now().Unix()-1)
|
||||
t.Cleanup(func() { setLegacyAccessTokenRetireAt(t, retireAt) })
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
|
||||
listed = accessTokenListResponse{}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
|
||||
assert.Nil(t, listed.Data.Legacy)
|
||||
legacyProof = issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
|
||||
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
|
||||
assert.Equal(t, http.StatusNotFound, response.Code)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "")
|
||||
assert.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacy, stored.GetAccessToken())
|
||||
|
||||
var revoked []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.revoke").Order("id").Find(&revoked).Error)
|
||||
require.Len(t, revoked, 2)
|
||||
for i, want := range []string{
|
||||
fmt.Sprintf(`"token_ref":"%s"`, scopedToken.TokenHash),
|
||||
fmt.Sprintf(`"legacy":true,"token_ref":"%s"`, model.AccessTokenFingerprint(legacy)),
|
||||
} {
|
||||
require.NotNil(t, revoked[i].Other.Op)
|
||||
params, err := common.Marshal(revoked[i].Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), want)
|
||||
}
|
||||
var history []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&history).Error)
|
||||
require.NotEmpty(t, history)
|
||||
encoded, err := common.Marshal(history)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{old, "new-token", "body-must-not-be-logged", "Authorization"} {
|
||||
for _, secret := range []string{scoped, legacy, browser, proof, legacyProof, otherRaw, otherProof, "Authorization"} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
|
||||
// The scope dictionary labels stored grants, so it must not shrink to what the
|
||||
// viewer can grant today.
|
||||
func TestAccessTokenScopeDictionaryIgnoresTheViewersGrants(t *testing.T) {
|
||||
user, _ := setupAccessTokenAudit(t)
|
||||
require.NoError(t, model.DB.Model(user).Update("role", common.RoleCommonUser).Error)
|
||||
router := newAccessTokenTestRouter()
|
||||
_, browser := createAccessTokenTestSession(t, user.Id, "dictionary-session")
|
||||
response := accessTokenRequest(router, "GET", "/api/user/access_tokens/scopes", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var dictionary struct {
|
||||
Data struct {
|
||||
Resources []struct {
|
||||
Resource string `json:"resource"`
|
||||
LabelKey string `json:"label_key"`
|
||||
Actions []struct {
|
||||
Action string `json:"action"`
|
||||
LabelKey string `json:"label_key"`
|
||||
} `json:"actions"`
|
||||
} `json:"resources"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &dictionary))
|
||||
labels := map[string]string{}
|
||||
for _, resource := range dictionary.Data.Resources {
|
||||
for _, action := range resource.Actions {
|
||||
labels[resource.Resource+":"+action.Action] = resource.LabelKey + " / " + action.LabelKey
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "Profile / View", labels["profile:read"])
|
||||
assert.Equal(t, "API keys / Reveal full keys", labels["api_key:reveal"])
|
||||
assert.Equal(t, "Users / Edit", labels["user:write"])
|
||||
assert.Equal(t, "System settings / Edit", labels["option:write"])
|
||||
for _, permission := range authz.AllPermissions() {
|
||||
assert.Contains(t, labels, service.AccessTokenScopeOf(permission))
|
||||
}
|
||||
|
||||
response = accessTokenRequest(router, "GET", "/api/user/access_tokens/catalog", browser, "", "")
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
assert.NotContains(t, response.Body.String(), `"resource":"user"`, "the grant catalog stays filtered")
|
||||
}
|
||||
|
||||
func TestAccessTokenCreationChecksGrantBeforeConsumingProof(t *testing.T) {
|
||||
user, legacy := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "create-session")
|
||||
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, "", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
|
||||
expiresAt := time.Now().Unix() + 7*24*60*60
|
||||
for _, test := range []struct {
|
||||
name, body, code string
|
||||
role, status int
|
||||
proofScopes []string
|
||||
proofExpiry int64
|
||||
}{
|
||||
{"common user cannot grant user management", `{"name":"ci","scopes":["user:write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleCommonUser, http.StatusBadRequest, []string{"user:write"}, 0},
|
||||
{"administrator cannot grant a permission they lack", `{"name":"ci","scopes":["channel:sensitive_write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleAdminUser, http.StatusBadRequest, []string{"channel:sensitive_write"}, 0},
|
||||
{"unknown scope", `{"name":"ci","scopes":["profile:admin"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:admin"}, 0},
|
||||
{"empty grant", `{"name":"ci","scopes":[],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
|
||||
{"expiry under one hour", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, time.Now().Unix()+60), "ACCESS_TOKEN_EXPIRY_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, time.Now().Unix() + 60},
|
||||
{"blank name", `{"name":" ","scopes":["profile:read"],"expires_at":0}`, "ACCESS_TOKEN_NAME_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
|
||||
{"proof for other scopes", `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`, "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
|
||||
{"proof for other expiry", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, expiresAt), "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require.NoError(t, model.DB.Model(user).Update("role", test.role).Error)
|
||||
proof := issueAccessTokenGenerateProof(t, identity, test.proofExpiry, test.proofScopes...)
|
||||
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, test.body)
|
||||
assert.Equal(t, test.status, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
|
||||
var consumed int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
|
||||
assert.Zero(t, consumed, "a rejected grant must not spend the verification")
|
||||
})
|
||||
}
|
||||
tokens, err := model.ListUserAccessTokens(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tokens)
|
||||
|
||||
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
|
||||
proof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read", "profile:read")
|
||||
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":" ci ","scopes":["profile:read","usage:read","profile:read"],"expires_at":0}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var created struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
Item struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenRef string `json:"token_ref"`
|
||||
TokenHint string `json:"token_hint"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"item"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &created))
|
||||
raw := created.Data.Token
|
||||
require.True(t, strings.HasPrefix(raw, model.AccessTokenPrefix))
|
||||
assert.Len(t, raw, len(model.AccessTokenPrefix)+43)
|
||||
assert.Equal(t, "ci", created.Data.Item.Name)
|
||||
assert.Equal(t, []string{"profile:read", "usage:read"}, created.Data.Item.Scopes)
|
||||
assert.Zero(t, created.Data.Item.ExpiresAt, "expires_at 0 creates a token that never expires")
|
||||
assert.Equal(t, model.AccessTokenFingerprint(raw), created.Data.Item.TokenRef)
|
||||
assert.Equal(t, raw[len(raw)-4:], created.Data.Item.TokenHint)
|
||||
var columns []map[string]any
|
||||
require.NoError(t, model.DB.Table("user_access_tokens").Find(&columns).Error)
|
||||
require.Len(t, columns, 1)
|
||||
encodedRow, err := common.Marshal(columns[0])
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encodedRow), raw, "the database stores only the token hash")
|
||||
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
|
||||
stored, err := model.GetUserById(user.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacy, stored.GetAccessToken(), "creating a token never replaces the legacy token")
|
||||
|
||||
var generated model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.generate").First(&generated).Error)
|
||||
require.NotNil(t, generated.Other.Op)
|
||||
params, err := common.Marshal(generated.Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, created.Data.Item.TokenRef))
|
||||
assert.Contains(t, string(params), `"scopes":["profile:read","usage:read"]`)
|
||||
var history []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&history).Error)
|
||||
encoded, err := common.Marshal(history)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, browser, proof} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenUpdateRenamesFreelyAndChangesGrantOnlyWithProof(t *testing.T) {
|
||||
user, _ := setupAccessTokenAudit(t)
|
||||
router := newAccessTokenTestRouter()
|
||||
identity, browser := createAccessTokenTestSession(t, user.Id, "update-session")
|
||||
raw, token := createScopedAccessToken(t, user.Id, 0, "profile:read")
|
||||
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", token.Id)
|
||||
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
_, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
|
||||
|
||||
response := accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":" renamed "}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
stored, err := model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", stored.Name)
|
||||
assert.Equal(t, []string{"profile:read"}, stored.GetScopes(), "a rename keeps the grant")
|
||||
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":"ci","scopes":["usage:read"]}`)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
|
||||
|
||||
generateProof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read")
|
||||
for _, test := range []struct {
|
||||
name, path, body, proof, code string
|
||||
status int
|
||||
}{
|
||||
{"permission the owner lacks", tokenPath, `{"name":"ci","scopes":["channel:sensitive_write"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "channel:sensitive_write"), "ACCESS_TOKEN_SCOPE_FORBIDDEN", http.StatusBadRequest},
|
||||
{"empty grant", tokenPath, `{"name":"ci","scopes":[]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "ACCESS_TOKEN_SCOPE_INVALID", http.StatusBadRequest},
|
||||
{"another user's token", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "ACCESS_TOKEN_NOT_FOUND", http.StatusNotFound},
|
||||
{"proof for another token", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
|
||||
{"proof for another grant", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
|
||||
{"creation proof", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, generateProof, "SECURITY_PROOF_SCOPE_MISMATCH", http.StatusForbidden},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := accessTokenRequest(router, "PATCH", test.path, browser, test.proof, test.body)
|
||||
assert.Equal(t, test.status, response.Code)
|
||||
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
|
||||
var consumed int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
|
||||
assert.Zero(t, consumed, "a rejected change must not spend the verification")
|
||||
})
|
||||
}
|
||||
stored, err = model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", stored.Name, "a rejected grant change does not apply the rename either")
|
||||
assert.Equal(t, []string{"profile:read"}, stored.GetScopes())
|
||||
|
||||
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
|
||||
// The proof binds the grant as a set, so its order and repeats do not matter.
|
||||
widenProof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read", "profile:read", "profile:read")
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, widenProof, `{"name":"ci","scopes":["profile:read","usage:read"]}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
stored, err = model.GetUserAccessToken(user.Id, token.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"profile:read", "usage:read"}, stored.GetScopes())
|
||||
|
||||
proof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read")
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"," usage:read"]}`)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
var updated struct {
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &updated))
|
||||
assert.Equal(t, "ci", updated.Data.Name)
|
||||
assert.Equal(t, []string{"usage:read"}, updated.Data.Scopes)
|
||||
response = accessTokenRequest(router, "GET", "/api/user/self", raw, "", "")
|
||||
assert.Equal(t, http.StatusForbidden, response.Code, "a narrowed grant applies to the next request")
|
||||
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_SCOPE_DENIED"`)
|
||||
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"]}`)
|
||||
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
|
||||
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"access_token.rename", "access_token.update"}).Order("id").Find(&audits).Error)
|
||||
require.Len(t, audits, 3)
|
||||
assert.Equal(t, "access_token.rename", audits[0].Action)
|
||||
assert.Equal(t, "access_token.update", audits[1].Action)
|
||||
assert.Equal(t, "access_token.update", audits[2].Action)
|
||||
require.NotNil(t, audits[2].Other.Op)
|
||||
params, err := common.Marshal(audits[2].Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(params), `"previous_scopes":["profile:read","usage:read"]`)
|
||||
assert.Contains(t, string(params), `"scopes":["usage:read"]`)
|
||||
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, token.TokenHash))
|
||||
encoded, err := common.Marshal(audits)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, browser, widenProof, proof, generateProof} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
@@ -226,7 +621,6 @@ func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
|
||||
require.Error(t, err, "audit query failure must not look like never used")
|
||||
model.LOG_DB.Callback().Query().Remove("audit:fail")
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) }))
|
||||
require.Error(t, model.UpdateUserAccessToken(user.Id, "replacement"))
|
||||
_, err = model.RevokeUserAccessToken(user.Id)
|
||||
require.Error(t, err)
|
||||
model.DB.Callback().Update().Remove("audit:write-fail")
|
||||
@@ -347,7 +741,7 @@ func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
|
||||
recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
|
||||
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
|
||||
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
|
||||
model.RecordLog(user.Id, typ, "business entry")
|
||||
model.RecordLog(user.Id, typ, common.NewMessage("business entry"))
|
||||
}
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
||||
@@ -703,8 +1097,21 @@ func TestAuditDatabaseMatrix(t *testing.T) {
|
||||
require.NoError(t, db.First(&old).Error)
|
||||
assert.Equal(t, "historical login", old.Content)
|
||||
}
|
||||
require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token"))
|
||||
require.NoError(t, db.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "matrix-token").Error)
|
||||
require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade")
|
||||
scoped, scopedToken := createScopedAccessToken(t, 1, 0, "profile:read")
|
||||
found, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(scoped))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, scopedToken.Id, found.Id)
|
||||
assert.Equal(t, []string{"profile:read"}, found.GetScopes())
|
||||
require.Error(t, db.Create(&model.UserAccessToken{UserId: 1, Name: "duplicate", TokenHash: scopedToken.TokenHash, TokenHint: "dupe", Scopes: found.Scopes}).Error, "token hashes stay unique")
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: 99, Category: model.AuditCategoryAccessToken, TokenRef: scopedToken.TokenHash, Ip: "192.0.2.1", Success: true})
|
||||
byRef, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, TokenRef: scopedToken.TokenHash}, 0, 10, common.RoleCommonUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byRef, 1, "audit rows of a scoped token are found by its token_ref")
|
||||
_, err = model.DeleteUserAccessToken(1, scopedToken.Id)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.LOG_DB.Where("token_ref = ?", scopedToken.TokenHash).Delete(&model.AuditLog{}).Error)
|
||||
for _, timestamp := range []int64{101, 102, 103} {
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102})
|
||||
}
|
||||
@@ -788,7 +1195,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
|
||||
require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error)
|
||||
assert.Equal(t, "retained historical login", old.Content)
|
||||
}
|
||||
require.NoError(t, model.UpdateUserAccessToken(1, "independent-pat"))
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "independent-pat").Error)
|
||||
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403})
|
||||
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser)
|
||||
require.NoError(t, err)
|
||||
@@ -799,7 +1206,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status.LastUsedAt)
|
||||
assert.Equal(t, "192.0.2.8", status.LastUsedIp)
|
||||
model.RecordLog(1, model.LogTypeTopup, "independent business")
|
||||
model.RecordLog(1, model.LogTypeTopup, common.NewMessage("independent business"))
|
||||
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
|
||||
require.NoError(t, err)
|
||||
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupAdminUserTest promotes the enrolled operator to root and creates a
|
||||
// managed common user for the administrative endpoints to act on.
|
||||
func setupAdminUserTest(t *testing.T) (*model.User, service.AuthIdentity, *model.User) {
|
||||
t.Helper()
|
||||
operator, identity := setupSecurityEnrollmentTest(t)
|
||||
require.NoError(t, model.DB.Model(operator).Update("role", common.RoleRootUser).Error)
|
||||
require.NoError(t, model.PublishUserAuthCache(operator.Id))
|
||||
password, err := common.Password2Hash("managed-password")
|
||||
require.NoError(t, err)
|
||||
target := &model.User{Username: "managed-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "managed-aff", GitHubId: "managed-github"}
|
||||
require.NoError(t, model.DB.Create(target).Error)
|
||||
return operator, identity, target
|
||||
}
|
||||
|
||||
// adminUserRequest drives an admin user-management handler with the operator
|
||||
// context that AdminAuth would have populated for a dashboard session.
|
||||
func adminUserRequest(method, path, body, proof string, identity service.AuthIdentity, role int, params gin.Params, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
||||
response := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(response)
|
||||
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if proof != "" {
|
||||
c.Request.Header.Set("X-Security-Proof", proof)
|
||||
}
|
||||
c.Params = params
|
||||
c.Set("id", identity.UserID)
|
||||
c.Set("username", "enrollment-user")
|
||||
c.Set("role", role)
|
||||
c.Set("session_id", identity.SessionID)
|
||||
c.Set("auth_version", identity.UserAuthVersion)
|
||||
c.Set("session_version", identity.SessionVersion)
|
||||
handler(c)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestAdminUserRiskOperationsRequireProofBeforeMutation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, method, path string
|
||||
body func(target *model.User) string
|
||||
params func(target *model.User) gin.Params
|
||||
handler gin.HandlerFunc
|
||||
unchanged func(t *testing.T, target *model.User)
|
||||
}{
|
||||
{
|
||||
name: "hard delete", method: http.MethodDelete, path: "/api/user/:id", handler: DeleteUser,
|
||||
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
_, err := model.GetUserById(target.Id, false)
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manage disable", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
|
||||
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id) },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.UserStatusEnabled, stored.Status)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manage promote", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
|
||||
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"promote"}`, target.Id) },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.RoleCommonUser, stored.Role)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "manage delete", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
|
||||
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"delete"}`, target.Id) },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
_, err := model.GetUserById(target.Id, false)
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password reset", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
|
||||
body: func(target *model.User) string {
|
||||
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","password":"replacement-pass"}`, target.Id)
|
||||
},
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
stored, err := model.GetUserById(target.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "admin permission matrix", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
|
||||
body: func(target *model.User) string {
|
||||
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","admin_permissions":{}}`, target.Id)
|
||||
},
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", stored.DisplayName)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create administrator", method: http.MethodPost, path: "/api/user/", handler: CreateUser,
|
||||
body: func(*model.User) string { return `{"username":"new-admin","password":"admin-password-1","role":10}` },
|
||||
unchanged: func(t *testing.T, _ *model.User) {
|
||||
var count int64
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-admin").Count(&count).Error)
|
||||
assert.Zero(t, count)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "passkey reset", method: http.MethodDelete, path: "/api/user/:id/reset_passkey", handler: AdminResetPasskey,
|
||||
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
_, err := model.GetPasskeyByUserID(target.Id)
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "two-factor disable", method: http.MethodDelete, path: "/api/user/:id/2fa", handler: AdminDisable2FA,
|
||||
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
twoFA, err := model.GetTwoFAByUserId(target.Id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, twoFA.IsEnabled)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "built-in binding clear", method: http.MethodDelete, path: "/api/user/:id/bindings/:binding_type", handler: AdminClearUserBinding,
|
||||
params: func(target *model.User) gin.Params {
|
||||
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
|
||||
},
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "managed-github", stored.GitHubId)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom oauth unbind", method: http.MethodDelete, path: "/api/user/:id/oauth/bindings/:provider_id", handler: UnbindCustomOAuthByAdmin,
|
||||
params: func(target *model.User) gin.Params {
|
||||
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
|
||||
},
|
||||
unchanged: func(t *testing.T, target *model.User) {
|
||||
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, bindings, 1)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, identity, target := setupAdminUserTest(t)
|
||||
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: target.Id, CredentialID: "managed-passkey", PublicKey: "public-key"}).Error)
|
||||
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: target.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
||||
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
|
||||
var body string
|
||||
if test.body != nil {
|
||||
body = test.body(target)
|
||||
}
|
||||
var params gin.Params
|
||||
if test.params != nil {
|
||||
params = test.params(target)
|
||||
}
|
||||
response := adminUserRequest(test.method, test.path, body, "", identity, common.RoleRootUser, params, test.handler)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, "SECURITY_PROOF_REQUIRED", result.Code)
|
||||
test.unchanged(t, target)
|
||||
var flows int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
|
||||
assert.Zero(t, flows)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An administrator (not root) exercises the routine paths without touching the
|
||||
// Casbin policy store, which the security fixture does not provision.
|
||||
func TestAdminUserRoutineEditsDoNotRequireProof(t *testing.T) {
|
||||
_, identity, target := setupAdminUserTest(t)
|
||||
response := adminUserRequest(http.MethodPut, "/api/user/", fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Renamed","group":"default"}`, target.Id), "", identity, common.RoleAdminUser, nil, UpdateUser)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
stored, err := model.GetUserById(target.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Renamed", stored.DisplayName)
|
||||
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
|
||||
|
||||
response = adminUserRequest(http.MethodPost, "/api/user/", `{"username":"new-member","password":"member-password-1","role":1}`, "", identity, common.RoleAdminUser, nil, CreateUser)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
var count int64
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-member").Count(&count).Error)
|
||||
assert.EqualValues(t, 1, count)
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsNonStandardRole(t *testing.T) {
|
||||
_, identity, _ := setupAdminUserTest(t)
|
||||
for i, test := range []struct {
|
||||
name string
|
||||
role int
|
||||
operatorRole int
|
||||
}{
|
||||
{"between common and admin", 5, common.RoleAdminUser},
|
||||
{"negative", -1, common.RoleAdminUser},
|
||||
{"between admin and root", 99, common.RoleRootUser},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
username := fmt.Sprintf("odd-role-%d", i)
|
||||
body := fmt.Sprintf(`{"username":%q,"password":"member-password-1","role":%d}`, username, test.role)
|
||||
response := adminUserRequest(http.MethodPost, "/api/user/", body, "", identity, test.operatorRole, nil, CreateUser)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
assert.False(t, result.Success)
|
||||
assert.Empty(t, result.Code)
|
||||
var count int64
|
||||
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error)
|
||||
assert.Zero(t, count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserProofIsBoundToTargetAndActionAndConsumedOnce(t *testing.T) {
|
||||
_, identity, target := setupAdminUserTest(t)
|
||||
other := &model.User{Username: "other-user", Password: target.Password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "other-aff"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
disableTarget := service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, target.Id))}
|
||||
proof := issueSecurityEnrollmentProof(t, identity, disableTarget, service.VerificationMethodPassword)
|
||||
|
||||
for _, test := range []struct {
|
||||
name, body, code string
|
||||
params gin.Params
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"different action", fmt.Sprintf(`{"id":%d,"action":"enable"}`, target.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
|
||||
{"different user", fmt.Sprintf(`{"id":%d,"action":"disable"}`, other.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
|
||||
{"different scope", "", "SECURITY_PROOF_SCOPE_MISMATCH", gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}}, DeleteUser},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := adminUserRequest(http.MethodPost, "/api/user/manage", test.body, proof, identity, common.RoleRootUser, test.params, test.handler)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, test.code, result.Code)
|
||||
})
|
||||
}
|
||||
for _, id := range []int{target.Id, other.Id} {
|
||||
stored, err := model.GetUserById(id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.UserStatusEnabled, stored.Status)
|
||||
}
|
||||
|
||||
response := adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.UserStatusDisabled, stored.Status)
|
||||
var audit model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "user.manage").Last(&audit).Error)
|
||||
require.NotNil(t, audit.Other.Op)
|
||||
verificationMethod, err := common.Marshal(audit.Other.Op.Params["verification_method"])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `"password"`, string(verificationMethod))
|
||||
targetUserID, err := common.Marshal(audit.Other.Op.Params["target_user_id"])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, fmt.Sprint(target.Id), string(targetUserID))
|
||||
auditJSON, err := common.Marshal(audit)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(auditJSON), proof)
|
||||
|
||||
response = adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
|
||||
}
|
||||
|
||||
func TestAdminUserBindingProofDistinguishesBuiltInAndCustomBindings(t *testing.T) {
|
||||
_, identity, target := setupAdminUserTest(t)
|
||||
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
|
||||
custom := service.VerificationOperation{Scope: service.VerificationScopeAdminUserBindingClear, Context: []byte(fmt.Sprintf(`{"user_id":%d,"provider_id":7}`, target.Id))}
|
||||
proof := issueSecurityEnrollmentProof(t, identity, custom, service.VerificationMethodPassword)
|
||||
params := gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
|
||||
response := adminUserRequest(http.MethodDelete, "/api/user/:id/bindings/:binding_type", "", proof, identity, common.RoleRootUser, params, AdminClearUserBinding)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, "SECURITY_PROOF_CONTEXT_MISMATCH", result.Code)
|
||||
stored, err := model.GetUserById(target.Id, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "managed-github", stored.GitHubId)
|
||||
|
||||
params = gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
|
||||
response = adminUserRequest(http.MethodDelete, "/api/user/:id/oauth/bindings/:provider_id", "", proof, identity, common.RoleRootUser, params, UnbindCustomOAuthByAdmin)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, bindings)
|
||||
var audit model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_clear").Last(&audit).Error)
|
||||
require.NotNil(t, audit.Other.Op)
|
||||
providerID, err := common.Marshal(audit.Other.Op.Params["provider_id"])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, "7", string(providerID))
|
||||
}
|
||||
|
||||
func TestAdminUserVerificationPolicy(t *testing.T) {
|
||||
t.Run("requires an administrator", func(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
|
||||
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
|
||||
})
|
||||
t.Run("falls back to the password without a second factor", func(t *testing.T) {
|
||||
_, identity, _ := setupAdminUserTest(t)
|
||||
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodPassword, Available: true}}, requirements.Methods)
|
||||
})
|
||||
t.Run("prefers an enrolled second factor", func(t *testing.T) {
|
||||
operator, identity, _ := setupAdminUserTest(t)
|
||||
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: operator.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
||||
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserManage)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
|
||||
})
|
||||
t.Run("a legacy token cannot present a proof", func(t *testing.T) {
|
||||
operator, identity, target := setupAdminUserTest(t)
|
||||
require.NoError(t, model.DB.Model(operator).Update("access_token", "legacy-admin-token").Error)
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, target.Id))}, service.VerificationMethodPassword)
|
||||
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), "legacy-admin-token", proof, "")
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
|
||||
_, err := model.GetUserById(target.Id, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("a user:write token completes the deletion on its own proof", func(t *testing.T) {
|
||||
operator, _, target := setupAdminUserTest(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.ExternalIdentityClaim{}, &model.Token{}))
|
||||
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:write")
|
||||
router := newAccessTokenTestRouter()
|
||||
response := accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
|
||||
body := decodeSecurityEnrollmentResponse(t, response)
|
||||
require.True(t, body.Success, body.Message)
|
||||
var proof service.SecurityProof
|
||||
require.NoError(t, common.Unmarshal(body.Data, &proof))
|
||||
|
||||
response = accessTokenRequest(router, http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), raw, proof.ProofToken, "")
|
||||
body = decodeSecurityEnrollmentResponse(t, response)
|
||||
require.True(t, body.Success, body.Message)
|
||||
_, err := model.GetUserById(target.Id, true)
|
||||
assert.Error(t, err)
|
||||
var audit model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "user.delete").Last(&audit).Error)
|
||||
assert.Equal(t, "access_token", audit.AuthMethod)
|
||||
auditJSON, err := common.Marshal(audit)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{raw, proof.ProofToken, "enrollment-password"} {
|
||||
assert.NotContains(t, string(auditJSON), secret)
|
||||
}
|
||||
})
|
||||
t.Run("a user:read token cannot obtain a proof", func(t *testing.T) {
|
||||
operator, _, target := setupAdminUserTest(t)
|
||||
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:read")
|
||||
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code)
|
||||
var flows int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
|
||||
assert.Zero(t, flows)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type astraSamplingHandler struct{}
|
||||
|
||||
type astraSamplingTaskPayload struct {
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
func (astraSamplingHandler) Type() string { return service.AstraSamplingTaskType }
|
||||
func (astraSamplingHandler) Enabled() bool { return service.AstraSamplingEnabled() }
|
||||
func (astraSamplingHandler) Interval() time.Duration { return service.AstraSamplingSchedulerInterval() }
|
||||
func (astraSamplingHandler) ScheduleFromCreation() bool { return true }
|
||||
func (astraSamplingHandler) NewPayload() any { return nil }
|
||||
|
||||
func (astraSamplingHandler) ScheduleDue(ctx context.Context, now time.Time) (bool, error) {
|
||||
channels, err := service.AstraSamplingChannelsDue(ctx, now)
|
||||
return len(channels) > 0, err
|
||||
}
|
||||
|
||||
func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask, runnerID string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 25*time.Minute)
|
||||
defer cancel()
|
||||
payload := astraSamplingTaskPayload{}
|
||||
if err := task.DecodePayload(&payload); err != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
|
||||
return
|
||||
}
|
||||
channels, err := service.AstraSamplingChannelsDue(ctx, time.Now())
|
||||
if payload.Force {
|
||||
channels, err = service.AstraSamplingChannels(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, errors.New("channel query failed"))
|
||||
return
|
||||
}
|
||||
state := service.AstraSamplingState{Rounds: []service.AstraSamplingRound{}}
|
||||
var mu sync.Mutex
|
||||
var persistErr error
|
||||
// Persist before sending, and after every probe. A failed lease or restart
|
||||
// never resumes a paid probe; unfinished records remain visible as errors.
|
||||
persist := func() bool {
|
||||
if persistErr != nil {
|
||||
return false
|
||||
}
|
||||
persistErr = model.UpdateSystemTaskState(task.TaskID, runnerID, state)
|
||||
if persistErr != nil {
|
||||
cancel()
|
||||
}
|
||||
return persistErr == nil
|
||||
}
|
||||
jobs := make(chan int)
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < 2; worker++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for id := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
index := len(state.Rounds)
|
||||
state.Rounds = append(state.Rounds, service.AstraSamplingRound{ID: common.GetRandomString(16), ChannelID: id, StartedAt: time.Now().Unix(), Version: fingerprint.Version, Result: fingerprint.Result{Status: "running"}})
|
||||
ok := persist()
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
channel, loadErr := model.GetChannelById(id, true)
|
||||
samples := []fingerprint.Sample{}
|
||||
reason := ""
|
||||
usage := []fingerprint.ProbeResponse{}
|
||||
if loadErr != nil || channel.Status != common.ChannelStatusEnabled {
|
||||
reason = "channel_unavailable"
|
||||
} else {
|
||||
for probe := 0; probe < fingerprint.ProbeCount && ctx.Err() == nil; probe++ {
|
||||
mu.Lock()
|
||||
state.Rounds[index].Sent++
|
||||
ok = persist()
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
answer, count, probeErr := runAstraFingerprintProbe(ctx, channel, probe)
|
||||
usage = append(usage, answer)
|
||||
if probeErr != nil {
|
||||
if errors.Is(probeErr, fingerprint.ErrInvalidSample) {
|
||||
reason = "invalid_sample"
|
||||
} else {
|
||||
reason = "transport_error"
|
||||
break
|
||||
}
|
||||
} else {
|
||||
samples = append(samples, fingerprint.Sample{Text: answer.Text, Count: count})
|
||||
}
|
||||
mu.Lock()
|
||||
state.Rounds[index].Usage = append([]fingerprint.ProbeResponse(nil), usage...)
|
||||
ok = persist()
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result, scoreErr := fingerprint.Score(samples)
|
||||
if scoreErr != nil {
|
||||
reason = "scoring_error"
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
reason = "interrupted"
|
||||
}
|
||||
if reason != "" && reason != "invalid_sample" {
|
||||
result.Status = "error"
|
||||
}
|
||||
mu.Lock()
|
||||
state.Rounds[index].Result = result
|
||||
state.Rounds[index].Reason = reason
|
||||
state.Rounds[index].Usage = usage
|
||||
state.Rounds[index].FinishedAt = time.Now().Unix()
|
||||
if persisted := persist(); persisted {
|
||||
service.InvalidateGroupQualityCache()
|
||||
publishGroupStatusChange()
|
||||
if !payload.Force {
|
||||
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
|
||||
persistErr = err
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, channel := range channels {
|
||||
select {
|
||||
case jobs <- channel.Id:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
workers.Wait()
|
||||
if persistErr != nil || ctx.Err() != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, errors.New("sampling interrupted"))
|
||||
return
|
||||
}
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, map[string]any{"channels": len(state.Rounds), "model": fingerprint.Model}, nil)
|
||||
}
|
||||
|
||||
func runAstraFingerprintProbe(parent context.Context, channel *model.Channel, index int) (fingerprint.ProbeResponse, int, error) {
|
||||
return runModelDetectionProbe(parent, channel, index, "fingerprint")
|
||||
}
|
||||
|
||||
func runModelDetectionProbe(parent context.Context, channel *model.Channel, index int, kind string) (fingerprint.ProbeResponse, int, error) {
|
||||
ctx, cancel := context.WithTimeout(parent, 180*time.Second)
|
||||
defer cancel()
|
||||
var probe fingerprint.Probe
|
||||
var err error
|
||||
if kind == "fingerprint" {
|
||||
probe, err = fingerprint.ProbeAt(index)
|
||||
}
|
||||
if err != nil {
|
||||
return fingerprint.ProbeResponse{}, index, err
|
||||
}
|
||||
effort := "low"
|
||||
count, output := probe.Count, fingerprint.OutputTokens
|
||||
messages := []map[string]any{{"role": "user", "content": probe.Prompt}}
|
||||
if kind == "benchmark" {
|
||||
secondary, err := benchmark.ProbeAt(index)
|
||||
if err != nil {
|
||||
return fingerprint.ProbeResponse{}, index, err
|
||||
}
|
||||
count, output = index, benchmark.OutputTokens
|
||||
messages = []map[string]any{{"role": "system", "content": secondary.System}, {"role": "user", "content": secondary.Prompt}}
|
||||
}
|
||||
if kind == "vision" {
|
||||
count, output = 1, service.VisionOutputTokens
|
||||
effort = "medium"
|
||||
messages = []map[string]any{{"role": "user", "content": []map[string]any{
|
||||
{"type": "input_text", "text": service.VisionPrompt},
|
||||
{"type": "input_image", "image_url": service.VisionImageURL, "detail": "high"},
|
||||
}}}
|
||||
}
|
||||
if kind == "ciallo" {
|
||||
count, output = 1, service.VisionOutputTokens
|
||||
effort = "medium"
|
||||
messages = []map[string]any{{"role": "user", "content": service.CialloPrompt}}
|
||||
}
|
||||
input, _ := common.Marshal(messages)
|
||||
stream := true
|
||||
limit := uint(output)
|
||||
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: effort}}
|
||||
encoded, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return fingerprint.ProbeResponse{}, count, err
|
||||
}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/responses", bytes.NewReader(encoded))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if apiErr := middleware.SetupContextForSelectedChannel(c, channel, fingerprint.Model); apiErr != nil {
|
||||
return fingerprint.ProbeResponse{}, count, errors.New("channel setup failed")
|
||||
}
|
||||
info, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAIResponses, request, nil)
|
||||
if err != nil {
|
||||
return fingerprint.ProbeResponse{}, count, err
|
||||
}
|
||||
info.IsChannelTest = true
|
||||
info.DisablePing = true
|
||||
info.AstraDetectionDisabled = true
|
||||
info.AstraAutoSwitch = false
|
||||
adaptor, body, closer, apiErr := relay.PrepareResponsesRequest(c, info, request)
|
||||
if closer != nil {
|
||||
defer closer.Close()
|
||||
}
|
||||
if apiErr != nil {
|
||||
return fingerprint.ProbeResponse{}, count, errors.New("probe preparation failed")
|
||||
}
|
||||
{
|
||||
bounded, boundedCloser, boundErr := boundDetectionBody(body, request)
|
||||
if boundErr != nil {
|
||||
return fingerprint.ProbeResponse{}, count, boundErr
|
||||
}
|
||||
defer boundedCloser.Close()
|
||||
body = bounded
|
||||
}
|
||||
started := time.Now()
|
||||
response, err := adaptor.DoRequest(c, info, body)
|
||||
if err != nil {
|
||||
return fingerprint.ProbeResponse{}, count, errors.New("probe transport failed")
|
||||
}
|
||||
resp, ok := response.(*http.Response)
|
||||
if !ok || resp == nil {
|
||||
return fingerprint.ProbeResponse{}, count, errors.New("unsupported probe transport")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
stopClose := context.AfterFunc(ctx, func() { _ = resp.Body.Close() })
|
||||
defer stopClose()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fingerprint.ProbeResponse{}, count, errors.New("probe HTTP failure")
|
||||
}
|
||||
// Deliberately bypass DoResponse: this is a platform-funded probe, with no
|
||||
// customer token, wallet charge, session binding or generated response relay.
|
||||
var firstTokenMS *int64
|
||||
answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type"), func() {
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
firstTokenMS = &elapsed
|
||||
})
|
||||
answer.FirstTokenMS = firstTokenMS
|
||||
return answer, count, err
|
||||
}
|
||||
|
||||
func TestAstraFingerprint(c *gin.Context) {
|
||||
if !service.AstraSamplingEnabled() {
|
||||
c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Astra sampling is disabled"})
|
||||
return
|
||||
}
|
||||
latest, err := model.GetLatestSystemTasks([]string{service.AstraSamplingTaskType})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if last := latest[service.AstraSamplingTaskType]; last != nil && time.Now().Unix()-last.CreatedAt < 300 {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "message": "Please wait five minutes between sampling rounds"})
|
||||
return
|
||||
}
|
||||
task, created, err := service.EnqueueSystemTask(service.AstraSamplingTaskType, astraSamplingTaskPayload{Force: true})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"task_id": task.TaskID, "created": created}})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAstraSamplingUsesIsolatedResponsesWithoutGuard(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
bodyPreview, readErr := io.ReadAll(r.Body)
|
||||
require.NoError(t, readErr)
|
||||
r.Body = io.NopCloser(strings.NewReader(string(bodyPreview)))
|
||||
assert.EqualValues(t, fingerprint.OutputTokens, gjson.GetBytes(bodyPreview, "max_output_tokens").Int())
|
||||
assert.Equal(t, "low", gjson.GetBytes(bodyPreview, "reasoning.effort").String())
|
||||
assert.Contains(t, gjson.GetBytes(bodyPreview, "input.0.content").String(), "355")
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if r.URL.Path != "/v1/responses" || gjson.GetBytes(body, "model").String() != fingerprint.Model || !gjson.GetBytes(body, "stream").Bool() {
|
||||
t.Errorf("wrong probe request %s %s", r.URL.Path, body)
|
||||
}
|
||||
if gjson.GetBytes(body, "previous_response_id").Exists() || gjson.GetBytes(body, "conversation").Exists() {
|
||||
t.Error("probe carried customer session")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.6-luna\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"[1,2,3]\"}]}]}}\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
url := server.URL
|
||||
channel := &model.Channel{Id: 1, Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
|
||||
r, count, err := runAstraFingerprintProbe(context.Background(), channel, 0)
|
||||
if err != nil || r.DeclaredModel != "gpt-5.6-luna" || count < 292 || count > 304 || requests != 1 {
|
||||
t.Fatalf("probe %+v count=%d requests=%d err=%v", r, count, requests, err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -27,8 +29,10 @@ var auditContentTemplates = map[string]string{
|
||||
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
|
||||
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
|
||||
"user.passkey_register": "Registered a passkey",
|
||||
"access_token.generate": "Generated a system access token",
|
||||
"access_token.revoke": "Revoked the system access token",
|
||||
"access_token.generate": "Generated an access token",
|
||||
"access_token.revoke": "Revoked an access token",
|
||||
"access_token.rename": "Renamed an access token",
|
||||
"access_token.update": "Changed access token permissions",
|
||||
"user.2fa_setup": "Started two-factor authentication setup",
|
||||
"user.2fa_enable": "Enabled two-factor authentication",
|
||||
"user.2fa_disable_self": "Disabled two-factor authentication",
|
||||
@@ -43,6 +47,11 @@ var auditContentTemplates = map[string]string{
|
||||
"user.reset_passkey": "Reset the user passkey",
|
||||
"option.update": "Updated system setting ${key}",
|
||||
|
||||
"option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_failed": "Passkey domain update failed",
|
||||
|
||||
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
|
||||
"channel.update": "Updated channel ${name} (ID: ${id})",
|
||||
"channel.delete": "Deleted channel ${name} (ID: ${id})",
|
||||
@@ -61,8 +70,36 @@ var auditContentTemplates = map[string]string{
|
||||
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
|
||||
"redemption.delete_batch": "Batch deleted ${count} redemption codes",
|
||||
|
||||
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
|
||||
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
|
||||
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
|
||||
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
|
||||
"subscription.user_quota_reset": "Administrator reset the quota of subscription plan ${plan_title} (ID: ${plan_id})",
|
||||
}
|
||||
|
||||
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
|
||||
confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0
|
||||
params := map[string]any{"success": err == nil, "confirmed": confirmed}
|
||||
if change != nil {
|
||||
params["domains"] = strings.Join(change.RemovedRPIDs, ", ")
|
||||
params["removed_rp_ids"] = change.RemovedRPIDs
|
||||
params["known"] = change.AffectedCredentials
|
||||
params["unknown"] = change.UnknownCredentials
|
||||
params["previous_rp_id"] = change.PreviousRPID
|
||||
params["effective_rp_id"] = change.EffectiveRPID
|
||||
}
|
||||
action := "option.passkey_domains"
|
||||
if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) {
|
||||
action = "option.passkey_domains_blocked"
|
||||
} else if err != nil {
|
||||
action = "option.passkey_domains_failed"
|
||||
} else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 {
|
||||
action = "option.passkey_domains_confirmed"
|
||||
}
|
||||
auditInfo := &model.AuditRequestInfo{
|
||||
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
|
||||
Status: c.Writer.Status(), Success: err == nil,
|
||||
}
|
||||
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c)
|
||||
markAuditLogged(c)
|
||||
}
|
||||
|
||||
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
|
||||
@@ -131,6 +168,12 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m
|
||||
}
|
||||
params["code"] = code
|
||||
}
|
||||
if c.GetBool("use_access_token") {
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
params["token_ref"] = c.GetString("access_token_ref")
|
||||
}
|
||||
var auditInfo *model.AuditRequestInfo
|
||||
if success, ok := params["success"].(bool); ok {
|
||||
auditInfo = &model.AuditRequestInfo{
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestSecurityLoginCodeCompletesOnce(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
||||
require.NoError(t, model.DB.Create(factor).Error)
|
||||
challenge, err := service.StartLoginVerification(user, "password")
|
||||
challenge, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, challenge)
|
||||
code, err := totp.GenerateCode(factor.Secret, time.Now())
|
||||
@@ -121,7 +121,7 @@ func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
||||
require.NoError(t, model.DB.Create(factor).Error)
|
||||
challenge, err := service.StartLoginVerification(user, "password")
|
||||
challenge, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
method := "2fa"
|
||||
switch change {
|
||||
@@ -183,7 +183,7 @@ func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) {
|
||||
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
|
||||
flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
|
||||
} else {
|
||||
pending, err := service.StartLoginVerification(user, "oauth:github")
|
||||
pending, err := service.StartLoginVerification(user, "oauth:github", nil)
|
||||
require.NoError(t, err)
|
||||
parentToken = pending.FlowToken
|
||||
flowToken, challenge = beginSecurityLoginPasskey(t, parentToken)
|
||||
@@ -225,7 +225,7 @@ func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) {
|
||||
func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
pending, err := service.StartLoginVerification(user, "password")
|
||||
pending, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
requests := make([]string, 2)
|
||||
for index := range requests {
|
||||
@@ -266,7 +266,7 @@ func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T)
|
||||
func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
pending, err := service.StartLoginVerification(user, "password")
|
||||
pending, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "user_sessions" {
|
||||
@@ -410,7 +410,7 @@ func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
first, err := service.StartLoginVerification(user, "password")
|
||||
first, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken)
|
||||
if otherUser {
|
||||
@@ -418,7 +418,7 @@ func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) {
|
||||
require.NoError(t, model.DB.Create(user).Error)
|
||||
newSecurityLoginPasskey(t, user.Id)
|
||||
}
|
||||
second, err := service.StartLoginVerification(user, "password")
|
||||
second, err := service.StartLoginVerification(user, "password", nil)
|
||||
require.NoError(t, err)
|
||||
body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
|
||||
require.NoError(t, err)
|
||||
@@ -816,3 +816,310 @@ func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
|
||||
assert.Zero(t, provider.exchangeCalls)
|
||||
assert.Zero(t, provider.userInfoCalls)
|
||||
}
|
||||
|
||||
// legacyGitHubOAuthProvider follows the GitHub provider contract: the numeric
|
||||
// account ID identifies the user, Extra["legacy_id"] carries the username and
|
||||
// the verified email list is served on demand. Lookups go through the real
|
||||
// github_id model queries.
|
||||
type legacyGitHubOAuthProvider struct {
|
||||
authFlowTestOAuthProvider
|
||||
providerUserID string
|
||||
legacyID string
|
||||
verifiedEmails []string
|
||||
verifiedEmailsErr error
|
||||
verifiedEmailCalls int
|
||||
}
|
||||
|
||||
func (*legacyGitHubOAuthProvider) GetName() string { return "GitHub" }
|
||||
func (provider *legacyGitHubOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
|
||||
return &oauth.OAuthUser{ProviderUserID: provider.providerUserID, Username: provider.legacyID, Extra: map[string]any{"legacy_id": provider.legacyID}}, nil
|
||||
}
|
||||
func (provider *legacyGitHubOAuthProvider) GetVerifiedEmails(context.Context, *oauth.OAuthToken) ([]string, error) {
|
||||
provider.verifiedEmailCalls++
|
||||
return provider.verifiedEmails, provider.verifiedEmailsErr
|
||||
}
|
||||
func (*legacyGitHubOAuthProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsGitHubIdAlreadyTaken(providerUserID)
|
||||
}
|
||||
func (*legacyGitHubOAuthProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
user.GitHubId = providerUserID
|
||||
return user.FillUserByGitHubId()
|
||||
}
|
||||
func (*legacyGitHubOAuthProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
user.GitHubId = providerUserID
|
||||
}
|
||||
func (*legacyGitHubOAuthProvider) GetProviderPrefix() string { return "github_" }
|
||||
func (*legacyGitHubOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
|
||||
|
||||
// legacyGitHubOAuthLogin registers provider for one test and completes an OAuth
|
||||
// login callback with it.
|
||||
func legacyGitHubOAuthLogin(t *testing.T, provider *legacyGitHubOAuthProvider) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
const slug = "github-legacy-login-test"
|
||||
oauth.Register(slug, provider)
|
||||
t.Cleanup(func() { oauth.Unregister(slug) })
|
||||
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)})
|
||||
require.NoError(t, err)
|
||||
router := gin.New()
|
||||
router.GET("/api/oauth/:provider", HandleOAuth)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil))
|
||||
return response
|
||||
}
|
||||
|
||||
// legacyGitHubBindingAudits returns the account binding audit rows in creation
|
||||
// order together with their encoded parameters.
|
||||
func legacyGitHubBindingAudits(t *testing.T) ([]model.AuditLog, []string) {
|
||||
t.Helper()
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_bind").Order("id").Find(&audits).Error)
|
||||
params := make([]string, 0, len(audits))
|
||||
for _, audit := range audits {
|
||||
require.NotNil(t, audit.Other.Op)
|
||||
encoded, err := common.Marshal(audit.Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
params = append(params, string(encoded))
|
||||
}
|
||||
return audits, params
|
||||
}
|
||||
|
||||
func TestOAuthLoginLegacyGitHubBindingRequiresAccountEvidence(t *testing.T) {
|
||||
const declined = "This GitHub account cannot be linked to an existing account automatically, please sign in or register another way and then link GitHub in account settings"
|
||||
tests := []struct {
|
||||
name string
|
||||
existingGitHubID string
|
||||
existingDeleted bool
|
||||
withoutEmail bool
|
||||
legacyID string
|
||||
verifiedEmails []string
|
||||
verifiedEmailsErr error
|
||||
registerEnabled bool
|
||||
expectLogin bool
|
||||
expectMigration bool
|
||||
expectNewAccount bool
|
||||
expectEmailCalls int
|
||||
expectAuditParams string
|
||||
}{
|
||||
{name: "numeric legacy value registers a new account", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true},
|
||||
{name: "numeric legacy value with registration disabled", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}},
|
||||
{name: "soft-deleted legacy row registers a new account", existingGitHubID: "octocat-legacy", existingDeleted: true, legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true},
|
||||
{
|
||||
name: "verified email match migrates", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
|
||||
verifiedEmails: []string{"other@example.com", " Legacy-GitHub@Example.com "}, expectLogin: true, expectMigration: true, expectEmailCalls: 1,
|
||||
// No SMTP server is configured in tests, so the notification attempt is recorded as failed.
|
||||
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verified_email_matched":true,"success":true,"notification_failed":true}`,
|
||||
},
|
||||
{
|
||||
name: "no matching verified email declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
|
||||
verifiedEmails: []string{"other@example.com"}, registerEnabled: true, expectEmailCalls: 1,
|
||||
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`,
|
||||
},
|
||||
{
|
||||
name: "verified emails failure declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy",
|
||||
verifiedEmailsErr: errors.New("emails unavailable"), registerEnabled: true, expectEmailCalls: 1,
|
||||
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"verified_emails_unavailable"}`,
|
||||
},
|
||||
{
|
||||
name: "account without email declines before asking the provider", existingGitHubID: "octocat-legacy", withoutEmail: true, legacyID: "octocat-legacy",
|
||||
verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true,
|
||||
expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
previousRegister := common.RegisterEnabled
|
||||
common.RegisterEnabled = test.registerEnabled
|
||||
t.Cleanup(func() { common.RegisterEnabled = previousRegister })
|
||||
existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: test.existingGitHubID}
|
||||
if test.withoutEmail {
|
||||
existing.Email = ""
|
||||
}
|
||||
require.NoError(t, model.DB.Create(existing).Error)
|
||||
if test.existingDeleted {
|
||||
require.NoError(t, model.DB.Delete(existing).Error)
|
||||
}
|
||||
provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID, verifiedEmails: test.verifiedEmails, verifiedEmailsErr: test.verifiedEmailsErr}
|
||||
response := legacyGitHubOAuthLogin(t, provider)
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
User struct {
|
||||
Id int `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.Equal(t, test.expectLogin, result.Success, response.Body.String())
|
||||
assert.Equal(t, test.expectEmailCalls, provider.verifiedEmailCalls)
|
||||
|
||||
var reloaded model.User
|
||||
require.NoError(t, model.DB.Unscoped().First(&reloaded, existing.Id).Error)
|
||||
audits, params := legacyGitHubBindingAudits(t)
|
||||
if test.expectAuditParams == "" {
|
||||
assert.Empty(t, audits)
|
||||
} else {
|
||||
require.Len(t, audits, 1)
|
||||
assert.Equal(t, existing.Id, audits[0].UserId)
|
||||
assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole)
|
||||
assert.Equal(t, test.expectMigration, audits[0].Success)
|
||||
assert.JSONEq(t, test.expectAuditParams, params[0])
|
||||
}
|
||||
if test.expectMigration {
|
||||
assert.Equal(t, existing.Id, result.Data.User.Id)
|
||||
assert.Equal(t, "900001", reloaded.GitHubId)
|
||||
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
|
||||
return
|
||||
}
|
||||
assert.Equal(t, test.existingGitHubID, reloaded.GitHubId, "the existing binding must stay untouched")
|
||||
if test.expectNewAccount {
|
||||
var created model.User
|
||||
require.NoError(t, model.DB.Where("github_id = ?", "900001").First(&created).Error)
|
||||
assert.NotEqual(t, existing.Id, created.Id)
|
||||
assert.Equal(t, created.Id, result.Data.User.Id)
|
||||
return
|
||||
}
|
||||
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
||||
if test.expectAuditParams != "" {
|
||||
assert.Equal(t, declined, result.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginLegacyGitHubBindingMigratesAfterLoginVerification(t *testing.T) {
|
||||
for _, interference := range []string{"none", "pending id claimed by another account", "binding relinked meanwhile"} {
|
||||
t.Run(interference, func(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: "octocat-legacy"}
|
||||
require.NoError(t, model.DB.Create(existing).Error)
|
||||
factor := &model.TwoFA{UserId: existing.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
||||
require.NoError(t, model.DB.Create(factor).Error)
|
||||
provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}}
|
||||
response := legacyGitHubOAuthLogin(t, provider)
|
||||
var challenge struct {
|
||||
Success bool `json:"success"`
|
||||
Data service.LoginChallenge `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &challenge))
|
||||
require.True(t, challenge.Success, response.Body.String())
|
||||
require.True(t, challenge.Data.RequireVerification)
|
||||
assert.Equal(t, []service.VerificationMethodOption{{Method: "2fa", Available: true}}, challenge.Data.Methods)
|
||||
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
||||
assert.Zero(t, provider.verifiedEmailCalls, "a second factor is checked before the provider emails")
|
||||
var reloaded model.User
|
||||
require.NoError(t, model.DB.First(&reloaded, existing.Id).Error)
|
||||
assert.Equal(t, "octocat-legacy", reloaded.GitHubId, "the binding waits for the verification")
|
||||
audits, _ := legacyGitHubBindingAudits(t)
|
||||
assert.Empty(t, audits)
|
||||
|
||||
expectedGitHubID, expectLogin := "900001", true
|
||||
switch interference {
|
||||
case "pending id claimed by another account":
|
||||
other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: "900001"}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
expectedGitHubID, expectLogin = "octocat-legacy", false
|
||||
case "binding relinked meanwhile":
|
||||
require.NoError(t, model.DB.Model(existing).Update("github_id", "relinked-legacy").Error)
|
||||
expectedGitHubID = "relinked-legacy"
|
||||
}
|
||||
code, err := totp.GenerateCode(factor.Secret, time.Now())
|
||||
require.NoError(t, err)
|
||||
body, err := common.Marshal(map[string]string{"flow_token": challenge.Data.FlowToken, "code": code})
|
||||
require.NoError(t, err)
|
||||
for attempt := range 2 {
|
||||
response = securityEnrollmentRequest(http.MethodPost, "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, attempt == 0 && expectLogin, result.Success, response.Body.String())
|
||||
if attempt == 0 && !expectLogin {
|
||||
assert.Equal(t, "ACCOUNT_ALREADY_BOUND", result.Code)
|
||||
}
|
||||
if result.Success {
|
||||
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
|
||||
} else {
|
||||
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
require.NoError(t, model.DB.First(&reloaded, existing.Id).Error)
|
||||
assert.Equal(t, expectedGitHubID, reloaded.GitHubId)
|
||||
sessions, err := model.CountActiveUserSessions(existing.Id, time.Now().Unix())
|
||||
require.NoError(t, err)
|
||||
expectedSessions := 0
|
||||
if expectLogin {
|
||||
expectedSessions = 1
|
||||
}
|
||||
assert.EqualValues(t, expectedSessions, sessions, "the consumed challenge cannot be replayed")
|
||||
audits, params := legacyGitHubBindingAudits(t)
|
||||
if interference != "none" {
|
||||
assert.Empty(t, audits)
|
||||
return
|
||||
}
|
||||
require.Len(t, audits, 1)
|
||||
assert.Equal(t, existing.Id, audits[0].UserId)
|
||||
assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole)
|
||||
assert.True(t, audits[0].Success)
|
||||
// No SMTP server is configured in tests, so the notification attempt is recorded as failed.
|
||||
assert.JSONEq(t, `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verification_method":"2fa","success":true,"notification_failed":true}`, params[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthBindIgnoresLegacyGitHubUsernames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ownGitHubID string
|
||||
otherGitHubID string
|
||||
legacyID string
|
||||
expectBound bool
|
||||
}{
|
||||
{name: "another account's legacy username does not block binding", otherGitHubID: "octocat-legacy", legacyID: "octocat-legacy", expectBound: true},
|
||||
{name: "own legacy username is replaced", ownGitHubID: "octocat-legacy", otherGitHubID: "unrelated-legacy", legacyID: "octocat-legacy", expectBound: true},
|
||||
{name: "numeric legacy value does not collide", otherGitHubID: "424242", legacyID: "424242", expectBound: true},
|
||||
{name: "numeric ID held by another account is rejected", otherGitHubID: "900001", legacyID: "octocat-legacy"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
if test.ownGitHubID != "" {
|
||||
require.NoError(t, model.DB.Model(user).Update("github_id", test.ownGitHubID).Error)
|
||||
}
|
||||
other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: test.otherGitHubID}
|
||||
require.NoError(t, model.DB.Create(other).Error)
|
||||
const slug = "github-legacy-bind-test"
|
||||
oauth.Register(slug, &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID})
|
||||
t.Cleanup(func() { oauth.Unregister(slug) })
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"` + slug + `"}`)}, service.VerificationMethodPassword)
|
||||
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"`+slug+`","intent":"bind"}`, proof, identity, GenerateOAuthCode)
|
||||
var flow struct {
|
||||
Data struct {
|
||||
FlowToken string `json:"flow_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &flow))
|
||||
require.NotEmpty(t, flow.Data.FlowToken, started.Body.String())
|
||||
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+flow.Data.FlowToken+"&code=provider-code", "", "", identity, func(c *gin.Context) {
|
||||
c.Params = gin.Params{{Key: "provider", Value: slug}}
|
||||
HandleOAuth(c)
|
||||
})
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, test.expectBound, result.Success, response.Body.String())
|
||||
|
||||
var bound, untouched model.User
|
||||
require.NoError(t, model.DB.First(&bound, user.Id).Error)
|
||||
require.NoError(t, model.DB.First(&untouched, other.Id).Error)
|
||||
assert.Equal(t, test.otherGitHubID, untouched.GitHubId)
|
||||
if test.expectBound {
|
||||
assert.Equal(t, "900001", bound.GitHubId)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, test.ownGitHubID, bound.GitHubId)
|
||||
assert.Equal(t, "This GitHub account has already been bound", result.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -170,7 +170,7 @@ func writeAuthSessionError(c *gin.Context, err error) {
|
||||
// The response body only carries the generic AUTH_INTERNAL_ERROR
|
||||
// code; without this log the underlying Redis/database/session
|
||||
// failure is indistinguishable from the client side.
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
|
||||
logger.LogError(c.Request.Context(), common.LogText("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
|
||||
}
|
||||
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
|
||||
}
|
||||
@@ -179,7 +179,12 @@ func setAuthNoStore(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// authRotationData is empty when the change came through an access token,
|
||||
// which has no browser session to rotate.
|
||||
func authRotationData(bundle *service.AuthBundle) gin.H {
|
||||
if bundle == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"access_token": bundle.AccessToken,
|
||||
"token_type": bundle.TokenType,
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil)
|
||||
setupLogin(user, c)
|
||||
setupLogin(user, nil, c)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, recorder.Code)
|
||||
var stored model.User
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsAutomaticPoolAllFormats(t *testing.T) {
|
||||
db := setupModelListControllerTestDB(t)
|
||||
withSelfUseModeDisabled(t)
|
||||
oldGroups, oldRatios, oldModelRatios := setting.UserUsableGroups2JSONString(), ratio_setting.GroupRatio2JSONString(), ratio_setting.ModelRatio2JSONString()
|
||||
oldMemory := common.MemoryCacheEnabled
|
||||
common.MemoryCacheEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common.MemoryCacheEnabled = oldMemory
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(oldRatios))
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(oldModelRatios))
|
||||
})
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"list-a":"","list-b":""}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"list-a":1,"list-b":1}`))
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"zz-list-a":1,"zz-list-b":1,"zz-shared":1,"zz-disabled":1}`))
|
||||
require.NoError(t, db.Create(&[]model.Channel{{Id: 5601, Type: constant.ChannelTypeOpenAI, Status: 1}, {Id: 5602, Type: constant.ChannelTypeOpenAI, Status: 1}, {Id: 5603, Status: 2}}).Error)
|
||||
require.NoError(t, db.Create(&[]model.Ability{
|
||||
{ChannelId: 5601, Group: "list-a", Model: "zz-list-a", Enabled: true},
|
||||
{ChannelId: 5602, Group: "list-b", Model: "zz-list-b", Enabled: true},
|
||||
{ChannelId: 5601, Group: "list-a", Model: "zz-shared", Enabled: true},
|
||||
{ChannelId: 5602, Group: "list-b", Model: "zz-shared", Enabled: true},
|
||||
{ChannelId: 5603, Group: "list-b", Model: "zz-disabled", Enabled: true},
|
||||
{ChannelId: 5602, Group: "list-b", Model: "zz-unpriced", Enabled: true},
|
||||
}).Error)
|
||||
for _, preference := range []string{"cost", "speed", "stable"} {
|
||||
for _, limited := range []bool{false, true} {
|
||||
for _, format := range []int{constant.ChannelTypeOpenAI, constant.ChannelTypeAnthropic, constant.ChannelTypeGemini} {
|
||||
policy, err := common.Marshal(model.TokenRoutingPolicy{Version: 1, Mode: "smart", AutoPreference: preference})
|
||||
require.NoError(t, err)
|
||||
token := &model.Token{Group: "auto", RoutingPolicy: string(policy), ModelLimitsEnabled: limited, ModelLimits: "zz-list-b,zz-disabled,zz-unpriced"}
|
||||
engine := gin.New()
|
||||
engine.GET("/v1/models", func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "list-a")
|
||||
require.NoError(t, middleware.SetupContextForToken(c, token))
|
||||
ListModels(c, format)
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
engine.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
|
||||
require.Equal(t, 200, res.Code)
|
||||
var body struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(res.Body.Bytes(), &body))
|
||||
names := []string{}
|
||||
for _, item := range body.Data {
|
||||
names = append(names, item.ID)
|
||||
}
|
||||
for _, item := range body.Models {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
expected := []string{"zz-list-a", "zz-list-b", "zz-shared"}
|
||||
if limited {
|
||||
expected = []string{"zz-list-b"}
|
||||
}
|
||||
require.ElementsMatch(t, expected, names, "preference=%s limited=%v format=%d", preference, limited, format)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) {
|
||||
@@ -103,42 +111,16 @@ func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) {
|
||||
assert.Contains(t, recorder.Body.String(), "no task plugin usage schema")
|
||||
}
|
||||
|
||||
func setupBillingAliasOptionDB(t *testing.T) {
|
||||
t.Helper()
|
||||
previousDB := model.DB
|
||||
previousLogDB := model.LOG_DB
|
||||
previousType := common.MainDatabaseType()
|
||||
previousCache := common.MemoryCacheEnabled
|
||||
previousMap := common.OptionMap
|
||||
previousRedis := common.RedisEnabled
|
||||
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.AuditLog{}, &model.User{}))
|
||||
model.DB = database
|
||||
model.LOG_DB = database
|
||||
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
|
||||
common.MemoryCacheEnabled = false
|
||||
common.RedisEnabled = false
|
||||
common.OptionMap = map[string]string{}
|
||||
t.Cleanup(func() {
|
||||
model.DB = previousDB
|
||||
model.LOG_DB = previousLogDB
|
||||
common.SetMainDatabaseType(previousType)
|
||||
common.MemoryCacheEnabled = previousCache
|
||||
common.OptionMap = previousMap
|
||||
common.RedisEnabled = previousRedis
|
||||
model.InitChannelCache()
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateOptionAliasBillingExprUsesPluginSchema(t *testing.T) {
|
||||
setupBillingAliasOptionDB(t)
|
||||
database := modelManagementDB(t, "sqlite", "")
|
||||
require.NoError(t, database.AutoMigrate(&model.Log{}))
|
||||
const pluginKey = "billing-alias-probe"
|
||||
source := `
|
||||
export const meta = {
|
||||
apiVersion: 1, key: "billing-alias-probe", name: "Billing Alias Probe", version: "1.0.0", author: {name: "Test"},
|
||||
models: ["declared-model"], fetchMode: "per_task",
|
||||
usageSchema: {seconds: {type: "number", unit: "second"}}
|
||||
usageSchema: {seconds: {type: "number", unit: "second"}, image_count: {type: "number", unit: "count"}},
|
||||
usageProfiles: [{models: ["declared-model"], schema: {seconds: {type: "number", unit: "second"}}}]
|
||||
};
|
||||
export function buildSubmitRequest() { return {}; }
|
||||
export function parseSubmitResponse() { return {}; }
|
||||
@@ -191,13 +173,153 @@ export function parseTaskResult() { return {}; }
|
||||
assert.Equal(t, http.StatusOK, accepted.Code)
|
||||
assert.Contains(t, accepted.Body.String(), `"success":true`)
|
||||
|
||||
rejectedKey := putExpr("alias-model", `u("clips")`)
|
||||
rejectedKey := putExpr("alias-model", `u("image_count")`)
|
||||
assert.Equal(t, http.StatusOK, rejectedKey.Code)
|
||||
assert.Contains(t, rejectedKey.Body.String(), `"success":false`)
|
||||
assert.Contains(t, rejectedKey.Body.String(), `usage key \"clips\" is not declared`)
|
||||
assert.Contains(t, rejectedKey.Body.String(), `usage key \"image_count\" is not declared`)
|
||||
|
||||
rejectedDeclared := putExpr("declared-model", `u("image_count")`)
|
||||
assert.Contains(t, rejectedDeclared.Body.String(), `"success":false`)
|
||||
assert.Contains(t, rejectedDeclared.Body.String(), `usage key \"image_count\" is not declared`)
|
||||
|
||||
unresolvable := putExpr("unknown-alias-model", `u("seconds")`)
|
||||
assert.Equal(t, http.StatusOK, unresolvable.Code)
|
||||
assert.Contains(t, unresolvable.Body.String(), `"success":false`)
|
||||
assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema")
|
||||
}
|
||||
|
||||
// Covers configuration persistence, validation, reservation and reconciliation
|
||||
// through the same public entry points used by the relay.
|
||||
func TestPreConsumePolicyDatabaseMatrix(t *testing.T) {
|
||||
previousConfig := config.GlobalConfig.ExportAllConfigs()
|
||||
previousUnit, previousBatch := common.QuotaPerUnit, common.BatchUpdateEnabled
|
||||
common.QuotaPerUnit, common.BatchUpdateEnabled = 500000, false
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(previousConfig))
|
||||
common.QuotaPerUnit, common.BatchUpdateEnabled = previousUnit, previousBatch
|
||||
})
|
||||
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
|
||||
t.Run(dialect.kind, func(t *testing.T) {
|
||||
if dialect.env != "" && os.Getenv(dialect.env) == "" {
|
||||
t.Skip("set " + dialect.env + " to run this database")
|
||||
}
|
||||
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
|
||||
require.NoError(t, db.AutoMigrate(&model.Token{}))
|
||||
for key, value := range map[string]string{
|
||||
"quota_setting.trust_quota_usd": "10.5",
|
||||
"quota_setting.pre_consume_multiplier": "1.5",
|
||||
} {
|
||||
var response struct{ Success bool }
|
||||
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: value}, &response)
|
||||
require.True(t, response.Success)
|
||||
var saved model.Option
|
||||
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
|
||||
assert.Equal(t, value, saved.Value)
|
||||
}
|
||||
var savedOptions []model.Option
|
||||
require.NoError(t, db.Find(&savedOptions).Error)
|
||||
stored := map[string]string{}
|
||||
for _, option := range savedOptions {
|
||||
stored[option.Key] = option.Value
|
||||
}
|
||||
for range 2 {
|
||||
operation_setting.GetQuotaSetting().TrustQuotaUSD = 10
|
||||
operation_setting.GetQuotaSetting().PreConsumeMultiplier = 1
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(stored))
|
||||
assert.Equal(t, 10.5, operation_setting.GetQuotaSetting().TrustQuotaUSD)
|
||||
assert.Equal(t, 1.5, operation_setting.GetQuotaSetting().PreConsumeMultiplier)
|
||||
}
|
||||
for _, tc := range []struct{ key, value string }{
|
||||
{"trust_quota_usd", "-1"}, {"trust_quota_usd", "NaN"}, {"trust_quota_usd", "+Inf"},
|
||||
{"pre_consume_multiplier", "0"}, {"pre_consume_multiplier", "-0.5"},
|
||||
{"pre_consume_multiplier", "NaN"}, {"pre_consume_multiplier", "Inf"},
|
||||
{"pre_consume_multiplier", "1e309"}, {"pre_consume_multiplier", ""},
|
||||
} {
|
||||
key := "quota_setting." + tc.key
|
||||
var response struct{ Success bool }
|
||||
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: tc.value}, &response)
|
||||
assert.False(t, response.Success, "%s=%q", key, tc.value)
|
||||
var saved model.Option
|
||||
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
|
||||
assert.Equal(t, stored[key], saved.Value, "invalid saves must preserve the previous value")
|
||||
}
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"policy-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"policy-model":"tier(\"base\", p * 3 + c * 15)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
cases := []struct {
|
||||
name string
|
||||
threshold, multiplier float64
|
||||
wallet, token int
|
||||
force, unlimited bool
|
||||
wantHeld int
|
||||
}{
|
||||
{"above threshold", 10, 1, 5500000, 5500000, false, false, 0},
|
||||
{"custom threshold", 20, 1, 5500000, 5500000, false, false, 1500},
|
||||
{"zero disables bypass", 0, 1, 5500000, 5500000, false, false, 1500},
|
||||
{"wallet equals threshold", 10, 1, 5000000, 5500000, false, false, 1500},
|
||||
{"token equals threshold", 10, 1, 5500000, 5000000, false, false, 1500},
|
||||
{"fractional threshold", 10.5, 1, 5250001, 5250001, false, false, 0},
|
||||
{"unlimited token", 10, 1, 5500000, 0, false, true, 0},
|
||||
{"forced reservation", 10, 1, 5500000, 5500000, true, false, 1500},
|
||||
{"half input cost", 0, 0.5, 100000, 100000, false, false, 750},
|
||||
{"fractional multiple refunds excess", 0, 2.5, 100000, 100000, false, false, 3750},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, model.UpdateOption("quota_setting.trust_quota_usd", strconv.FormatFloat(tc.threshold, 'f', -1, 64)))
|
||||
require.NoError(t, model.UpdateOption("quota_setting.pre_consume_multiplier", strconv.FormatFloat(tc.multiplier, 'f', -1, 64)))
|
||||
user := model.User{Username: fmt.Sprintf("policy-user-%d", i), Quota: tc.wallet, Group: "default", AffCode: fmt.Sprintf("policy-aff-%d", i)}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
token := model.Token{UserId: user.Id, Key: fmt.Sprintf("policy-token-%d", i), RemainQuota: tc.token, UnlimitedQuota: tc.unlimited}
|
||||
require.NoError(t, db.Create(&token).Error)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
ctx.Set("token_quota", tc.token)
|
||||
info := &relaycommon.RelayInfo{UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, TokenUnlimited: tc.unlimited, ForcePreConsume: tc.force, OriginModelName: "policy-model", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
|
||||
info.UserSetting.BillingPreference = "wallet_only"
|
||||
price, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{MaxTokens: 10000})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, service.PreConsumeBilling(ctx, price.QuotaToPreConsume, info))
|
||||
assert.Equal(t, tc.wantHeld, info.FinalPreConsumedQuota)
|
||||
require.NoError(t, db.First(&user, user.Id).Error)
|
||||
assert.Equal(t, tc.wallet-tc.wantHeld, user.Quota)
|
||||
if !tc.unlimited {
|
||||
require.NoError(t, db.First(&token, token.Id).Error)
|
||||
assert.Equal(t, tc.token-tc.wantHeld, token.RemainQuota)
|
||||
}
|
||||
_, actual, _ := service.TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 100, Len: 1000})
|
||||
assert.Equal(t, 2250, actual)
|
||||
require.NoError(t, info.Billing.Settle(actual))
|
||||
require.NoError(t, info.Billing.Settle(actual))
|
||||
require.NoError(t, db.First(&user, user.Id).Error)
|
||||
assert.Equal(t, tc.wallet-actual, user.Quota)
|
||||
if !tc.unlimited {
|
||||
require.NoError(t, db.First(&token, token.Id).Error)
|
||||
assert.Equal(t, tc.token-actual, token.RemainQuota)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreConsumeMultiplierRejectsInvalidRuntimeAndOverflow(t *testing.T) {
|
||||
previous := config.GlobalConfig.ExportAllConfigs()
|
||||
t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(previous)) })
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"policy-overflow":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"policy-overflow":"tier(\"base\", p * 3)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
for _, multiplier := range []float64{0, -0.5, math.NaN(), math.Inf(1), math.MaxFloat64} {
|
||||
operation_setting.GetQuotaSetting().PreConsumeMultiplier = multiplier
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
info := &relaycommon.RelayInfo{OriginModelName: "policy-overflow", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
|
||||
_, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, info.Billing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BotBalanceQuery is a dedicated read-only API; its key grants no dashboard access.
|
||||
func BotBalanceQuery(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
key := os.Getenv("BOT_BALANCE_KEY")
|
||||
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
expected, supplied := sha256.Sum256([]byte(key)), sha256.Sum256([]byte(raw))
|
||||
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8192)
|
||||
var request struct {
|
||||
Email string `json:"email"`
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
|
||||
return
|
||||
}
|
||||
email := model.NormalizeEmail(request.Email)
|
||||
if (email == "") == (len(request.IDs) == 0) || len(request.IDs) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
|
||||
return
|
||||
}
|
||||
if email != "" {
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Address != email || len(email) > 50 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_email"})
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, id := range request.IDs {
|
||||
if id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
|
||||
return
|
||||
}
|
||||
}
|
||||
accounts, err := model.FindBotBalanceAccounts(email, request.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "query_failed"})
|
||||
return
|
||||
}
|
||||
if email != "" && len(accounts) != 1 {
|
||||
code := "not_found"
|
||||
if len(accounts) > 1 {
|
||||
code = "ambiguous_email"
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{"success": false, "code": code})
|
||||
return
|
||||
}
|
||||
unit, rate := common.QuotaPerUnit, operation_setting.USDExchangeRate
|
||||
if unit <= 0 || rate <= 0 || math.IsNaN(unit) || math.IsNaN(rate) || math.IsInf(unit, 0) || math.IsInf(rate, 0) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "invalid_currency"})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
active := account.Status == common.UserStatusEnabled
|
||||
item := gin.H{"id": account.ID, "active": active}
|
||||
if active {
|
||||
item["balance_cny"] = decimal.NewFromInt(int64(account.Quota)).Mul(decimal.NewFromFloat(rate)).DivRound(decimal.NewFromFloat(unit), 18).String()
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestBotBalanceQuery(t *testing.T) {
|
||||
key := strings.Repeat("b", 48)
|
||||
t.Setenv("BOT_BALANCE_KEY", key)
|
||||
oldUnit, oldRate := common.QuotaPerUnit, operation_setting.USDExchangeRate
|
||||
common.QuotaPerUnit, operation_setting.USDExchangeRate = 500000, 7.3
|
||||
t.Cleanup(func() { common.QuotaPerUnit, operation_setting.USDExchangeRate = oldUnit, oldRate })
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector = sqlite.Open(":memory:")
|
||||
if dialect == "mysql" {
|
||||
require.NotEmpty(t, os.Getenv("TEST_MYSQL_DSN"))
|
||||
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
|
||||
}
|
||||
if dialect == "postgres" {
|
||||
require.NotEmpty(t, os.Getenv("TEST_POSTGRES_DSN"))
|
||||
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("bot_%d_", time.Now().UnixNano())}})
|
||||
require.NoError(t, err)
|
||||
conn, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
conn.SetMaxOpenConns(1)
|
||||
oldDB := model.DB
|
||||
model.DB = db
|
||||
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&model.User{})); model.DB = oldDB; _ = conn.Close() })
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}))
|
||||
rows := []model.User{
|
||||
{Id: 1, Username: "bot1", Email: "a@example.com", Status: 1, Quota: 250000},
|
||||
{Id: 2, Username: "bot2", Email: "dup@example.com", Status: 1, Quota: 0},
|
||||
{Id: 3, Username: "bot3", Email: "dup@example.com", Status: 1, Quota: -500000},
|
||||
{Id: 4, Username: "bot4", Email: "disabled@example.com", Status: 2, Quota: 900000},
|
||||
{Id: 5, Username: "bot5", Email: "gone@example.com", Status: 1},
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].AffCode = fmt.Sprintf("bot%d", i)
|
||||
}
|
||||
require.NoError(t, db.Create(&rows).Error)
|
||||
require.NoError(t, db.Delete(&rows[4]).Error)
|
||||
r := gin.New()
|
||||
r.POST("/api/bot/balances", BotBalanceQuery)
|
||||
for _, tc := range []struct {
|
||||
name, body, token string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"missing key", `{}`, "", 401, "unauthorized"},
|
||||
{"wrong key", `{}`, "Bearer wrong", 401, "unauthorized"},
|
||||
{"empty", `{}`, "Bearer " + key, 400, "invalid_request"},
|
||||
{"both", `{"email":"a@example.com","ids":[1]}`, "Bearer " + key, 400, "invalid_request"},
|
||||
{"bad id", `{"ids":[0]}`, "Bearer " + key, 400, "invalid_request"},
|
||||
{"bad email", `{"email":"invalid"}`, "Bearer " + key, 400, "invalid_email"},
|
||||
{"deleted", `{"email":"gone@example.com"}`, "Bearer " + key, 409, "not_found"},
|
||||
{"duplicate", `{"email":"dup@example.com"}`, "Bearer " + key, 409, "ambiguous_email"},
|
||||
{"exact not substring", `{"email":"aa@example.com"}`, "Bearer " + key, 409, "not_found"},
|
||||
{"normalized", `{"email":" A@EXAMPLE.COM "}`, "Bearer " + key, 200, ""},
|
||||
{"batch", `{"ids":[1,2,3,4,5,999]}`, "Bearer " + key, 200, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(tc.body))
|
||||
req.Header.Set("Authorization", tc.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, tc.status, w.Code, w.Body.String())
|
||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, tc.code, gjson.Get(w.Body.String(), "code").String())
|
||||
if tc.status == 200 {
|
||||
assert.NotContains(t, w.Body.String(), "email")
|
||||
assert.NotContains(t, w.Body.String(), "quota")
|
||||
assert.NotContains(t, w.Body.String(), "password")
|
||||
assert.Equal(t, "3.65", gjson.Get(w.Body.String(), "data.0.balance_cny").String())
|
||||
if tc.name == "batch" {
|
||||
assert.Equal(t, int64(4), gjson.Get(w.Body.String(), "data.#").Int())
|
||||
assert.Equal(t, "0", gjson.Get(w.Body.String(), "data.1.balance_cny").String())
|
||||
assert.Equal(t, "-7.3", gjson.Get(w.Body.String(), "data.2.balance_cny").String())
|
||||
assert.False(t, gjson.Get(w.Body.String(), "data.3.active").Bool())
|
||||
assert.False(t, gjson.Get(w.Body.String(), "data.3.balance_cny").Exists())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Setenv("BOT_BALANCE_KEY", "")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(`{}`)))
|
||||
assert.Equal(t, 401, w.Code)
|
||||
t.Setenv("BOT_BALANCE_KEY", key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var chainEnqueue sync.Mutex
|
||||
var chainKinds = []string{"fingerprint", "ciallo", "vision"}
|
||||
|
||||
// All three child tasks are committed together, using the existing durable ledger.
|
||||
func enqueueChainTest(ctx context.Context, group string, channelID int) ([]model.DetectionRun, error) {
|
||||
chainEnqueue.Lock()
|
||||
defer chainEnqueue.Unlock()
|
||||
var runs []model.DetectionRun
|
||||
err := model.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var active []model.DetectionRun
|
||||
if err := tx.Where("group_name = ? AND request_key LIKE ? AND settled = ? AND deadline > ?", group, "chain:%", false, time.Now().Unix()).Order("created_at asc").Limit(1).Find(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(active) > 0 {
|
||||
id, _, _ := strings.Cut(active[0].ID, "_")
|
||||
return tx.Where("id IN ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}).Find(&runs).Error
|
||||
}
|
||||
id, err := common.GenerateRandomCharsKey(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
for _, kind := range chainKinds {
|
||||
runs = append(runs, model.DetectionRun{ID: id + "_" + kind, RequestKey: "chain:" + id + ":" + kind, ChannelID: channelID, GroupName: group, Kind: kind, Status: "pending", CreatedAt: now, Deadline: now + 600})
|
||||
}
|
||||
return tx.Create(&runs).Error
|
||||
})
|
||||
return runs, err
|
||||
}
|
||||
|
||||
func BotChainTest(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
key := os.Getenv("BOT_BALANCE_KEY")
|
||||
derived := sha256.Sum256([]byte("newapi-chaintest-v1:" + key))
|
||||
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
expected, supplied := sha256.Sum256([]byte(hex.EncodeToString(derived[:]))), sha256.Sum256([]byte(raw))
|
||||
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var runs []model.DetectionRun
|
||||
if c.Request.Method == http.MethodPost {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
|
||||
var input struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
if c.ShouldBindJSON(&input) != nil || strings.TrimSpace(input.Group) == "" || len(input.Group) > 128 {
|
||||
c.JSON(400, gin.H{"success": false, "code": "invalid_group"})
|
||||
return
|
||||
}
|
||||
input.Group = strings.TrimSpace(input.Group)
|
||||
channels, err := service.AstraSamplingChannels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
|
||||
return
|
||||
}
|
||||
slices.SortFunc(channels, func(a, b model.Channel) int { return a.Id - b.Id })
|
||||
selected := 0
|
||||
for _, channel := range channels {
|
||||
if slices.Contains(channel.GetGroups(), input.Group) {
|
||||
selected = channel.Id
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == 0 {
|
||||
c.JSON(404, gin.H{"success": false, "code": "group_unavailable"})
|
||||
return
|
||||
}
|
||||
runs, err = enqueueChainTest(c.Request.Context(), input.Group, selected)
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
go processDetection(context.Background(), run)
|
||||
}
|
||||
} else {
|
||||
id := c.Param("id")
|
||||
if len(id) != 32 || strings.ContainsAny(id, "_%") {
|
||||
c.JSON(404, gin.H{"success": false, "code": "not_found"})
|
||||
return
|
||||
}
|
||||
err := model.DB.WithContext(c.Request.Context()).Where("id IN ? AND request_key LIKE ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}, "chain:"+id+":%").Find(&runs).Error
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
|
||||
return
|
||||
}
|
||||
}
|
||||
data, err := chainTestResult(runs, time.Now().Unix())
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"success": false, "code": "not_found"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
func chainTestResult(runs []model.DetectionRun, now int64) (gin.H, error) {
|
||||
if len(runs) != 3 {
|
||||
return nil, errors.New("incomplete chain")
|
||||
}
|
||||
id, _, _ := strings.Cut(runs[0].ID, "_")
|
||||
results := make(map[string]any, 3)
|
||||
done := true
|
||||
for _, run := range runs {
|
||||
run = service.NormalizeVisionRun(run)
|
||||
state, text, match := "running", "", ""
|
||||
if run.Settled {
|
||||
state = "failed"
|
||||
var result fingerprint.Result
|
||||
if run.Status == "succeeded" && common.UnmarshalJsonStr(run.Result, &result) == nil {
|
||||
text = result.Closest
|
||||
if run.Kind == "fingerprint" {
|
||||
match = service.AstraSamplingResultStatus(result)
|
||||
state = match
|
||||
if state != "match" && state != "mismatch" && state != "inconclusive" {
|
||||
state = "failed"
|
||||
}
|
||||
} else if strings.TrimSpace(text) != "" {
|
||||
state = service.VisionQuality(text)
|
||||
if run.Kind == "ciallo" {
|
||||
state = service.CialloQuality(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if now >= run.Deadline {
|
||||
state = "failed"
|
||||
} else {
|
||||
done = false
|
||||
}
|
||||
results[run.Kind] = gin.H{"status": state, "text": text}
|
||||
}
|
||||
return gin.H{"id": id, "group": runs[0].GroupName, "deadline": runs[0].Deadline, "done": done, "results": results}, nil
|
||||
}
|
||||
@@ -157,7 +157,8 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
|
||||
for k := range headers {
|
||||
req.Header.Add(k, headers.Get(k))
|
||||
}
|
||||
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
|
||||
setting := channel.GetSetting()
|
||||
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,6 +276,67 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func getDeepSeekBalanceUSD(response DeepSeekUsageResponse, usdExchangeRate float64) (float64, error) {
|
||||
var usdBalance *string
|
||||
var cnyBalance *string
|
||||
|
||||
for i := range response.BalanceInfos {
|
||||
balanceInfo := &response.BalanceInfos[i]
|
||||
switch balanceInfo.Currency {
|
||||
case "USD":
|
||||
if usdBalance == nil {
|
||||
usdBalance = &balanceInfo.TotalBalance
|
||||
}
|
||||
case "CNY":
|
||||
if cnyBalance == nil {
|
||||
cnyBalance = &balanceInfo.TotalBalance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usdBalance != nil {
|
||||
balance, err := strconv.ParseFloat(*usdBalance, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if math.IsNaN(balance) || math.IsInf(balance, 0) {
|
||||
return 0, errors.New("USD balance must be finite")
|
||||
}
|
||||
if balance < 0 {
|
||||
return 0, errors.New("USD balance must be non-negative")
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
if cnyBalance == nil {
|
||||
return 0, errors.New("currency USD or CNY not found")
|
||||
}
|
||||
if math.IsNaN(usdExchangeRate) || math.IsInf(usdExchangeRate, 0) {
|
||||
return 0, errors.New("USD exchange rate must be finite")
|
||||
}
|
||||
if usdExchangeRate <= 0 {
|
||||
return 0, errors.New("USD exchange rate must be greater than zero")
|
||||
}
|
||||
|
||||
balanceCNY, err := strconv.ParseFloat(*cnyBalance, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if math.IsNaN(balanceCNY) || math.IsInf(balanceCNY, 0) {
|
||||
return 0, errors.New("CNY balance must be finite")
|
||||
}
|
||||
if balanceCNY < 0 {
|
||||
return 0, errors.New("CNY balance must be non-negative")
|
||||
}
|
||||
balanceUSD := decimal.NewFromFloat(balanceCNY).Div(decimal.NewFromFloat(usdExchangeRate)).InexactFloat64()
|
||||
if math.IsNaN(balanceUSD) || math.IsInf(balanceUSD, 0) {
|
||||
return 0, errors.New("converted USD balance must be finite")
|
||||
}
|
||||
if balanceUSD < 0 {
|
||||
return 0, errors.New("converted USD balance must be non-negative")
|
||||
}
|
||||
return balanceUSD, nil
|
||||
}
|
||||
|
||||
func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
|
||||
url := "https://api.deepseek.com/user/balance"
|
||||
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
|
||||
@@ -286,17 +348,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
index := -1
|
||||
for i, balanceInfo := range response.BalanceInfos {
|
||||
if balanceInfo.Currency == "CNY" {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return 0, errors.New("currency CNY not found")
|
||||
}
|
||||
balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64)
|
||||
balance, err := getDeepSeekBalanceUSD(response, operation_setting.USDExchangeRate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -369,6 +421,113 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
|
||||
return availableBalanceUsd, nil
|
||||
}
|
||||
|
||||
func updateChannelSub2APIBalance(channel *model.Channel) (float64, error) {
|
||||
baseURL := channel.GetBaseURL()
|
||||
if baseURL == "" {
|
||||
return 0, errors.New("Sub2API channel is missing base_url")
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/usage", strings.TrimRight(baseURL, "/"))
|
||||
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
type sub2apiUsageResponse struct {
|
||||
Balance *float64 `json:"balance"`
|
||||
Remaining *float64 `json:"remaining"`
|
||||
IsValid *bool `json:"isValid"`
|
||||
Subscription *struct {
|
||||
DailyLimitUSD *float64 `json:"daily_limit_usd"`
|
||||
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
|
||||
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
|
||||
} `json:"subscription"`
|
||||
}
|
||||
usage := sub2apiUsageResponse{}
|
||||
if err := common.Unmarshal(body, &usage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if usage.IsValid != nil && !*usage.IsValid {
|
||||
return 0, errors.New("Sub2API key is invalid (isValid=false)")
|
||||
}
|
||||
|
||||
balance := usage.Remaining
|
||||
if balance == nil {
|
||||
balance = usage.Balance
|
||||
}
|
||||
if balance == nil {
|
||||
return 0, errors.New("Sub2API /v1/usage response is missing remaining and balance fields (the key may belong to a subscription group without an available balance)")
|
||||
}
|
||||
// Sub2API reports remaining=-1 for a subscription group with no daily, weekly,
|
||||
// or monthly limit. A wallet balance can legitimately be negative, so detect
|
||||
// the unlimited case from the subscription limits rather than the value.
|
||||
subscription := usage.Subscription
|
||||
if subscription != nil && subscription.DailyLimitUSD == nil && subscription.WeeklyLimitUSD == nil && subscription.MonthlyLimitUSD == nil {
|
||||
return 0, errors.New("Sub2API /v1/usage reports an unlimited subscription without a finite balance")
|
||||
}
|
||||
channel.UpdateBalance(*balance)
|
||||
return *balance, nil
|
||||
}
|
||||
|
||||
// updateChannelNewAPIBalance reads the upstream key's remaining quota. When the
|
||||
// upstream also returns its owner's quota, the smaller of the two is what the
|
||||
// key can still spend. Quota converts to USD with the upstream's quota_per_unit.
|
||||
func updateChannelNewAPIBalance(channel *model.Channel) (float64, error) {
|
||||
baseURL := strings.TrimRight(channel.GetBaseURL(), "/")
|
||||
if baseURL == "" {
|
||||
return 0, errors.New("New API channel is missing base_url")
|
||||
}
|
||||
body, err := GetResponseBody("GET", baseURL+"/api/usage/token/", channel, GetAuthHeader(channel.Key))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var usage struct {
|
||||
Code bool `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TotalAvailable float64 `json:"total_available"`
|
||||
UnlimitedQuota bool `json:"unlimited_quota"`
|
||||
UserQuota *struct {
|
||||
TotalAvailable float64 `json:"total_available"`
|
||||
} `json:"user_quota"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := common.Unmarshal(body, &usage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !usage.Code {
|
||||
return 0, fmt.Errorf("New API usage query failed: %s", usage.Message)
|
||||
}
|
||||
remaining := usage.Data.TotalAvailable
|
||||
if usage.Data.UnlimitedQuota {
|
||||
remaining = math.Inf(1)
|
||||
}
|
||||
if userQuota := usage.Data.UserQuota; userQuota != nil {
|
||||
remaining = min(remaining, userQuota.TotalAvailable)
|
||||
}
|
||||
if math.IsInf(remaining, 1) {
|
||||
return 0, common.NewMessage(`The upstream key has unlimited quota. Turn on "Allow querying user quota" for this key on the upstream to see the real balance.`)
|
||||
}
|
||||
|
||||
body, err = GetResponseBody("GET", baseURL+"/api/status", channel, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var status struct {
|
||||
Data struct {
|
||||
QuotaPerUnit float64 `json:"quota_per_unit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := common.Unmarshal(body, &status); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if status.Data.QuotaPerUnit <= 0 {
|
||||
return 0, errors.New("New API /api/status response is missing quota_per_unit")
|
||||
}
|
||||
balance := remaining / status.Data.QuotaPerUnit
|
||||
channel.UpdateBalance(balance)
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
|
||||
key := strings.TrimSpace(channel.Key)
|
||||
info := &relaycommon.RelayInfo{
|
||||
@@ -402,7 +561,8 @@ func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, e
|
||||
request.Host = headers.Get(name)
|
||||
}
|
||||
}
|
||||
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
|
||||
setting := channel.GetSetting()
|
||||
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
|
||||
if err != nil {
|
||||
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
|
||||
}
|
||||
@@ -473,7 +633,7 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
case constant.ChannelTypeAzure:
|
||||
return 0, errors.New("尚未实现")
|
||||
return 0, common.NewMessage("Not implemented yet")
|
||||
case constant.ChannelTypeCustom:
|
||||
baseURL = channel.GetBaseURL()
|
||||
//case common.ChannelTypeOpenAISB:
|
||||
@@ -492,8 +652,12 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
|
||||
return updateChannelOpenRouterBalance(channel)
|
||||
case constant.ChannelTypeMoonshot:
|
||||
return updateChannelMoonshotBalance(channel)
|
||||
case constant.ChannelTypeSub2API:
|
||||
return updateChannelSub2APIBalance(channel)
|
||||
case constant.ChannelTypeNewAPI:
|
||||
return updateChannelNewAPIBalance(channel)
|
||||
default:
|
||||
return 0, errors.New("尚未实现")
|
||||
return 0, common.NewMessage("Not implemented yet")
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
|
||||
|
||||
@@ -543,10 +707,7 @@ func UpdateChannelBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.IsMultiKey {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "多密钥渠道不支持余额查询",
|
||||
})
|
||||
common.ApiErrorT(c, "Multi-key channels do not support balance queries")
|
||||
return
|
||||
}
|
||||
result, err := updateChannelBalance(channel)
|
||||
@@ -588,7 +749,7 @@ func updateAllChannelsBalance() error {
|
||||
} else if result.RawResponse == "" {
|
||||
// err is nil & balance <= 0 means quota is used up
|
||||
if result.Balance <= 0 {
|
||||
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
|
||||
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "Insufficient balance")
|
||||
}
|
||||
}
|
||||
time.Sleep(common.RequestInterval)
|
||||
@@ -613,8 +774,8 @@ func UpdateAllChannelsBalance(c *gin.Context) {
|
||||
func AutomaticallyUpdateChannels(frequency int) {
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog("updating all channels")
|
||||
common.SysLog(common.LogText("updating all channels"))
|
||||
_ = updateAllChannelsBalance()
|
||||
common.SysLog("channels update done")
|
||||
common.SysLog(common.LogText("channels update done"))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
@@ -239,6 +240,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
}
|
||||
}
|
||||
|
||||
isStream = info.IsStream
|
||||
info.IsChannelTest = true
|
||||
info.InitChannelMeta(c)
|
||||
|
||||
@@ -292,7 +294,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
//// 创建一个用于日志的 info 副本,移除 ApiKey
|
||||
//logInfo := info
|
||||
//logInfo.ApiKey = ""
|
||||
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
|
||||
common.SysLog(common.LogText("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
|
||||
if err != nil {
|
||||
@@ -446,7 +448,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
|
||||
common.SysError(fmt.Sprintf(
|
||||
common.SysError(common.LogText(
|
||||
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
|
||||
channel.Id,
|
||||
channel.Name,
|
||||
@@ -507,15 +509,15 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
ModelName: info.OriginModelName,
|
||||
TokenName: "模型测试",
|
||||
TokenName: "Model test",
|
||||
Quota: quota,
|
||||
Content: "模型测试",
|
||||
Content: []*common.Message{common.NewMessage("Model test")},
|
||||
UseTimeSeconds: int(consumedTime),
|
||||
IsStream: info.IsStream,
|
||||
Group: info.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
common.SysLog(common.LogText("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: nil,
|
||||
@@ -666,7 +668,7 @@ func validateTestResponseBody(respBody []byte, isStream bool) error {
|
||||
}
|
||||
|
||||
func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
|
||||
return channel != nil && channel.Type == constant.ChannelTypeCodex
|
||||
return channel != nil
|
||||
}
|
||||
|
||||
func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
|
||||
@@ -863,7 +865,7 @@ func TestChannel(c *gin.Context) {
|
||||
//}()
|
||||
testModel := c.Query("model")
|
||||
endpointType := c.Query("endpoint_type")
|
||||
isStream, _ := strconv.ParseBool(c.Query("stream"))
|
||||
isStream, _ := strconv.ParseBool(c.DefaultQuery("stream", "true"))
|
||||
testUserID, err := resolveChannelTestUserID(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -937,7 +939,12 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
|
||||
|
||||
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||
if milliseconds > disableThreshold {
|
||||
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||
// The reason is stored with the channel and sent in the disable
|
||||
// notice; no request states a language here.
|
||||
err := errors.New(i18n.Translate("", i18n.MsgChannelResponseTimeExceeded, map[string]any{
|
||||
"Seconds": fmt.Sprintf("%.2f", float64(milliseconds)/1000.0),
|
||||
"Threshold": fmt.Sprintf("%.2f", float64(disableThreshold)/1000.0),
|
||||
}))
|
||||
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
|
||||
shouldBanChannel = true
|
||||
}
|
||||
@@ -1084,8 +1091,9 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
|
||||
// stops promptly. mode selects the channel set: an empty mode falls back to the
|
||||
// configured monitor ChannelTestMode (scheduled behavior), while a manual
|
||||
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
|
||||
// is set the root user is notified on completion. Cross-instance execution is
|
||||
// guarded by the system task per-type lock, so no process-local guard is needed.
|
||||
// is set, the administrators who receive channel notices are notified on
|
||||
// completion. Cross-instance execution is guarded by the system task per-type
|
||||
// lock, so no process-local guard is needed.
|
||||
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
@@ -1103,7 +1111,9 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
|
||||
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
|
||||
if notify && (ctx == nil || ctx.Err() == nil) {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
service.NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
|
||||
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
|
||||
})
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
@@ -1138,15 +1148,12 @@ func TestAllChannels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务",
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
},
|
||||
})
|
||||
msg := common.NewMessage("A channel test task is already running or queued. Cannot start this manual task")
|
||||
common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -34,19 +35,13 @@ func ClearChannelAffinityCache(c *gin.Context) {
|
||||
}
|
||||
|
||||
if ruleName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Missing parameter rule_name, or use all=true to clear everything"))
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupSub2APIBalanceTest wires an in-memory SQLite DB into model.DB so
|
||||
// Channel.UpdateBalance can persist, mirroring the pattern used by other
|
||||
// controller tests.
|
||||
func setupSub2APIBalanceTest(t *testing.T) {
|
||||
t.Helper()
|
||||
originalDB := model.DB
|
||||
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Discard,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
require.NoError(t, database.AutoMigrate(&model.Channel{}))
|
||||
model.DB = database
|
||||
t.Cleanup(func() {
|
||||
model.DB = originalDB
|
||||
})
|
||||
}
|
||||
|
||||
func newSub2APITestChannel(t *testing.T, serverURL string) *model.Channel {
|
||||
t.Helper()
|
||||
channel := &model.Channel{
|
||||
Type: constant.ChannelTypeSub2API,
|
||||
BaseURL: &serverURL,
|
||||
Key: "sk-test",
|
||||
Name: "sub2api-test",
|
||||
}
|
||||
require.NoError(t, model.DB.Create(channel).Error)
|
||||
return channel
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceRemainingPreferred(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/v1/usage", r.URL.Path)
|
||||
assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"balance": 100.0, "remaining": 986.17194888, "isValid": true, "unit": "USD"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
balance, err := updateChannelSub2APIBalance(channel)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 986.17194888, balance, 1e-9)
|
||||
assert.InDelta(t, 986.17194888, channel.Balance, 1e-9)
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceWalletFallback(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"balance": 42.5, "isValid": true, "unit": "USD"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
balance, err := updateChannelSub2APIBalance(channel)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 42.5, balance, 1e-9)
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceSubscriptionKeyMissingFields(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"balance": null, "remaining": null, "isValid": true, "mode": "unrestricted"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
_, err := updateChannelSub2APIBalance(channel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceInvalidKey(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"isValid": false}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
_, err := updateChannelSub2APIBalance(channel)
|
||||
require.EqualError(t, err, "Sub2API key is invalid (isValid=false)")
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceUnlimitedSubscription(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"remaining": -1, "isValid": true, "mode": "unrestricted", "subscription": {"daily_limit_usd": null, "weekly_limit_usd": null, "monthly_limit_usd": null}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
require.NoError(t, model.DB.Model(channel).Update("balance", 12.5).Error)
|
||||
|
||||
_, err := updateChannelSub2APIBalance(channel)
|
||||
require.EqualError(t, err, "Sub2API /v1/usage reports an unlimited subscription without a finite balance")
|
||||
|
||||
stored := model.Channel{}
|
||||
require.NoError(t, model.DB.First(&stored, channel.Id).Error)
|
||||
assert.InDelta(t, 12.5, stored.Balance, 1e-9)
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceNegativeWalletBalance(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"balance": -1, "remaining": -1, "isValid": true, "mode": "unrestricted", "unit": "USD"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
balance, err := updateChannelSub2APIBalance(channel)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, -1, balance, 1e-9)
|
||||
}
|
||||
|
||||
func TestUpdateChannelSub2APIBalanceMissingBaseURL(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
empty := ""
|
||||
channel := &model.Channel{BaseURL: &empty, Key: "sk-test"}
|
||||
_, err := updateChannelSub2APIBalance(channel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateStandardChannelBalanceRoutesSub2API(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"balance": 77.7, "isValid": true, "unit": "USD"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := newSub2APITestChannel(t, server.URL)
|
||||
result, err := updateChannelBalance(channel)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 77.7, result.Balance, 1e-9)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetDeepSeekBalanceUSD(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseJSON string
|
||||
usdExchangeRate float64
|
||||
want float64
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "prefers USD when USD precedes CNY",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"12.50"},{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 12.5,
|
||||
},
|
||||
{
|
||||
name: "prefers USD when CNY precedes USD",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"},{"currency":"USD","total_balance":"12.50"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 12.5,
|
||||
},
|
||||
{
|
||||
name: "converts CNY when USD is absent",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
name: "returns error when USD and CNY are absent",
|
||||
responseJSON: `{"balance_infos":[{"currency":"EUR","total_balance":"10.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "currency USD or CNY not found",
|
||||
},
|
||||
{
|
||||
name: "returns USD parse error instead of falling back to CNY",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"invalid"},{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "invalid syntax",
|
||||
},
|
||||
{
|
||||
name: "rejects NaN USD balance",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"NaN"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "USD balance must be finite",
|
||||
},
|
||||
{
|
||||
name: "rejects negative USD balance",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"-1.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "USD balance must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "rejects positive infinity CNY balance",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"+Inf"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "CNY balance must be finite",
|
||||
},
|
||||
{
|
||||
name: "rejects negative CNY balance",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"-7.30"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
wantErrContains: "CNY balance must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "returns error for non-positive CNY exchange rate",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: 0,
|
||||
wantErrContains: "USD exchange rate must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "rejects NaN CNY exchange rate",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: math.NaN(),
|
||||
wantErrContains: "USD exchange rate must be finite",
|
||||
},
|
||||
{
|
||||
name: "rejects positive infinity CNY exchange rate",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: math.Inf(1),
|
||||
wantErrContains: "USD exchange rate must be finite",
|
||||
},
|
||||
{
|
||||
name: "rejects CNY conversion overflow",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"1.7976931348623157e+308"}]}`,
|
||||
usdExchangeRate: math.SmallestNonzeroFloat64,
|
||||
wantErrContains: "converted USD balance must be finite",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var response DeepSeekUsageResponse
|
||||
require.NoError(t, common.Unmarshal([]byte(test.responseJSON), &response))
|
||||
|
||||
balance, err := getDeepSeekBalanceUSD(response, test.usdExchangeRate)
|
||||
if test.wantErrContains != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), test.wantErrContains)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, test.want, balance, 1e-12)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChannelNewAPIBalanceUsesTheSmallerRemainingQuota(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
usage string
|
||||
want float64
|
||||
wantErr string
|
||||
}{
|
||||
{name: "user quota below the key quota", usage: `{"code":true,"data":{"total_available":2000000,"unlimited_quota":false,"user_quota":{"total_available":500000}}}`, want: 0.5},
|
||||
{name: "key quota below the user quota", usage: `{"code":true,"data":{"total_available":500000,"unlimited_quota":false,"user_quota":{"total_available":2000000}}}`, want: 0.5},
|
||||
{name: "unlimited key uses the user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true,"user_quota":{"total_available":3000000}}}`, want: 3},
|
||||
{name: "key quota alone without user quota", usage: `{"code":true,"data":{"total_available":1500000,"unlimited_quota":false}}`, want: 1.5},
|
||||
{name: "unlimited key without user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true}}`, wantErr: "Allow querying user quota"},
|
||||
{name: "upstream rejects the key", usage: `{"success":false,"message":"invalid token"}`, wantErr: "invalid token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupSub2APIBalanceTest(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/usage/token/":
|
||||
assert.Equal(t, "Bearer sk-upstream", r.Header.Get("Authorization"))
|
||||
_, _ = w.Write([]byte(tc.usage))
|
||||
case "/api/status":
|
||||
assert.Empty(t, r.Header.Get("Authorization"), "the key is not sent to the public status endpoint")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":1000000}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
baseURL := server.URL + "/"
|
||||
channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Key: "sk-upstream", Name: "new-api-upstream"}
|
||||
require.NoError(t, model.DB.Create(channel).Error)
|
||||
|
||||
result, err := updateChannelBalance(channel)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, tc.want, result.Balance, 1e-9)
|
||||
assert.InDelta(t, tc.want, channel.Balance, 1e-9)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
)
|
||||
|
||||
type inferenceEndpointStatus struct {
|
||||
Status int `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type inferenceModel struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"root"`
|
||||
MaxModelLen *int64 `json:"max_model_len"`
|
||||
}
|
||||
|
||||
type inferenceMetric struct {
|
||||
Name string `json:"name"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type inferenceStatus struct {
|
||||
SampledAt int64 `json:"sampled_at"`
|
||||
Endpoints map[string]inferenceEndpointStatus `json:"endpoints"`
|
||||
Version string `json:"version"`
|
||||
Models []inferenceModel `json:"models"`
|
||||
Metrics []inferenceMetric `json:"metrics"`
|
||||
RawMetrics string `json:"raw_metrics"`
|
||||
}
|
||||
|
||||
func GetVLLMChannelStatus(c *gin.Context) {
|
||||
getInferenceChannelStatus(c, constant.ChannelTypeVLLM)
|
||||
}
|
||||
|
||||
func GetSGLangChannelStatus(c *gin.Context) {
|
||||
getInferenceChannelStatus(c, constant.ChannelTypeSGLang)
|
||||
}
|
||||
|
||||
func getInferenceChannelStatus(c *gin.Context, channelType int) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid channel id"})
|
||||
return
|
||||
}
|
||||
channel, err := model.GetChannelById(id, true)
|
||||
if err != nil || channel == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "Channel not found"})
|
||||
return
|
||||
}
|
||||
if channel.Type != channelType {
|
||||
common.ApiError(c, errors.New("Channel type does not match the status endpoint"))
|
||||
return
|
||||
}
|
||||
status, err := fetchInferenceStatus(c.Request.Context(), channel)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, status)
|
||||
}
|
||||
|
||||
// fetchInferenceStatus reads only fixed paths on an operator-configured channel.
|
||||
// Each endpoint can fail independently; upstream errors never echo credentials.
|
||||
func fetchInferenceStatus(ctx context.Context, channel *model.Channel) (*inferenceStatus, error) {
|
||||
if channel.Type != constant.ChannelTypeVLLM && channel.Type != constant.ChannelTypeSGLang {
|
||||
return nil, errors.New("This operation is only supported for vLLM or SGLang channels")
|
||||
}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(channel.GetBaseURL()), "/")
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil || parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||
return nil, errors.New("Invalid inference server address")
|
||||
}
|
||||
key, _, keyErr := channel.GetNextEnabledKey()
|
||||
if keyErr != nil {
|
||||
return nil, errors.New("No enabled channel key")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
headers, err := buildFetchModelsHeaders(channel, key)
|
||||
if err != nil {
|
||||
return nil, errors.New("Invalid channel header override")
|
||||
}
|
||||
if (key == "" || key == "EMPTY") && headers.Get("Authorization") == "Bearer "+key {
|
||||
headers.Del("Authorization")
|
||||
}
|
||||
settings := channel.GetSetting()
|
||||
client, err := service.GetHttpClientWithProxySettings(settings.Proxy, settings)
|
||||
if err != nil {
|
||||
return nil, errors.New("Invalid channel proxy")
|
||||
}
|
||||
// Do not mutate the shared client or forward channel credentials on redirects.
|
||||
statusClient := *client
|
||||
statusClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
versionPath, metricPrefix := "/version", "vllm:"
|
||||
if channel.Type == constant.ChannelTypeSGLang {
|
||||
versionPath, metricPrefix = "/server_info", "sglang:"
|
||||
}
|
||||
paths := []string{"/health", versionPath, "/v1/models", "/metrics"}
|
||||
results := make([]struct {
|
||||
inferenceEndpointStatus
|
||||
body []byte
|
||||
at int64
|
||||
}, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
for i, path := range paths {
|
||||
wg.Go(func() {
|
||||
result := &results[i]
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
|
||||
if err != nil {
|
||||
result.Error = "request_failed"
|
||||
return
|
||||
}
|
||||
req.Header = headers.Clone()
|
||||
req.Host = headers.Get("Host")
|
||||
resp, err := statusClient.Do(req)
|
||||
if err != nil {
|
||||
result.Error = "request_failed"
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result.Status = resp.StatusCode
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Error = "http_error"
|
||||
return
|
||||
}
|
||||
const maxResponseBytes = 4 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
||||
if err != nil {
|
||||
result.Error = "request_failed"
|
||||
return
|
||||
}
|
||||
if len(body) > maxResponseBytes {
|
||||
result.Error = "response_too_large"
|
||||
return
|
||||
}
|
||||
result.body = body
|
||||
result.at = time.Now().UnixMilli()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
status := &inferenceStatus{
|
||||
SampledAt: time.Now().UnixMilli(),
|
||||
Endpoints: make(map[string]inferenceEndpointStatus, len(paths)),
|
||||
Models: []inferenceModel{},
|
||||
Metrics: []inferenceMetric{},
|
||||
}
|
||||
for i, path := range paths {
|
||||
result := &results[i]
|
||||
if result.Error == "" {
|
||||
switch path {
|
||||
case versionPath:
|
||||
// /server_info includes the launch configuration. Decode only the
|
||||
// version so API keys and other server settings never reach the browser.
|
||||
var version struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := common.Unmarshal(result.body, &version); err != nil || version.Version == "" {
|
||||
result.Error = "invalid_response"
|
||||
} else {
|
||||
status.Version = version.Version
|
||||
}
|
||||
case "/v1/models":
|
||||
var models struct {
|
||||
Data []inferenceModel `json:"data"`
|
||||
}
|
||||
if err := common.Unmarshal(result.body, &models); err != nil || models.Data == nil {
|
||||
result.Error = "invalid_response"
|
||||
} else {
|
||||
status.Models = models.Data
|
||||
}
|
||||
case "/metrics":
|
||||
metrics, err := parseInferenceMetrics(string(result.body), metricPrefix)
|
||||
if err != nil {
|
||||
result.Error = "invalid_response"
|
||||
} else {
|
||||
status.Metrics = metrics
|
||||
status.RawMetrics = string(result.body)
|
||||
status.SampledAt = result.at
|
||||
}
|
||||
}
|
||||
}
|
||||
status.Endpoints[path] = result.inferenceEndpointStatus
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseInferenceMetrics(raw string, prefix string) ([]inferenceMetric, error) {
|
||||
var parser expfmt.TextParser
|
||||
families, err := parser.TextToMetricFamilies(strings.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics := []inferenceMetric{}
|
||||
for name, family := range families {
|
||||
if !strings.HasPrefix(name, prefix) && name != "process_start_time_seconds" {
|
||||
continue
|
||||
}
|
||||
samples, err := expfmt.ExtractSamples(&expfmt.DecodeOptions{}, family)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sample := range samples {
|
||||
name := string(sample.Metric["__name__"])
|
||||
value := float64(sample.Value)
|
||||
if strings.HasSuffix(name, "_bucket") || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
continue
|
||||
}
|
||||
labels := make(map[string]string, len(sample.Metric)-1)
|
||||
for label, value := range sample.Metric {
|
||||
if label != "__name__" {
|
||||
labels[string(label)] = string(value)
|
||||
}
|
||||
}
|
||||
metrics = append(metrics, inferenceMetric{Name: name, Labels: labels, Value: value})
|
||||
}
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVLLMStatusPartialFailureAndCredentials(t *testing.T) {
|
||||
service.InitHttpClient()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "Bearer test-vllm-key", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "status-panel", r.Header.Get("X-Source"))
|
||||
switch r.URL.Path {
|
||||
case "/prefix/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/prefix/version":
|
||||
_, _ = w.Write([]byte(`{"version":"0.25.2"}`))
|
||||
case "/prefix/v1/models":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"served-model","root":"source/model","max_model_len":1048576}]}`))
|
||||
case "/prefix/metrics":
|
||||
http.Error(w, "test-vllm-key should not reach the browser", http.StatusForbidden)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
base := upstream.URL + "/prefix/"
|
||||
override := `{"X-Source":"status-panel"}`
|
||||
channel := &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &base, Key: "test-vllm-key", HeaderOverride: &override}
|
||||
status, err := fetchInferenceStatus(context.Background(), channel)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, status.Endpoints["/health"].Status)
|
||||
assert.Equal(t, "0.25.2", status.Version)
|
||||
require.Len(t, status.Models, 1)
|
||||
assert.Equal(t, "source/model", status.Models[0].Root)
|
||||
require.NotNil(t, status.Models[0].MaxModelLen)
|
||||
assert.EqualValues(t, 1048576, *status.Models[0].MaxModelLen)
|
||||
assert.Equal(t, inferenceEndpointStatus{Status: 403, Error: "http_error"}, status.Endpoints["/metrics"])
|
||||
assert.Empty(t, status.RawMetrics)
|
||||
assert.Empty(t, status.Metrics)
|
||||
body, err := common.Marshal(status)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(body), channel.Key)
|
||||
}
|
||||
|
||||
func TestVLLMStatusRejectsInvalidTargetsAndRedirects(t *testing.T) {
|
||||
service.InitHttpClient()
|
||||
for _, base := range []string{"", "file:///etc/passwd", "http://user:secret@localhost", "http://localhost?key=secret"} {
|
||||
_, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &base})
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
_, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeOllama})
|
||||
require.Error(t, err)
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("redirect must not forward channel credentials")
|
||||
}))
|
||||
defer target.Close()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Empty(t, r.Header.Get("Authorization"))
|
||||
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
status, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &upstream.URL, Key: "EMPTY"})
|
||||
require.NoError(t, err)
|
||||
for _, endpoint := range status.Endpoints {
|
||||
assert.Equal(t, inferenceEndpointStatus{Status: 302, Error: "http_error"}, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVLLMStatusInvalidAndOversizedResponses(t *testing.T) {
|
||||
service.InitHttpClient()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case "/version":
|
||||
_, _ = w.Write([]byte(`{"version":false}`))
|
||||
case "/v1/models":
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
case "/metrics":
|
||||
_, _ = w.Write([]byte(strings.Repeat(" ", (4<<20)+1)))
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
channel := &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &upstream.URL, Key: "EMPTY"}
|
||||
status, err := fetchInferenceStatus(context.Background(), channel)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 503, status.Endpoints["/health"].Status)
|
||||
assert.Equal(t, "invalid_response", status.Endpoints["/version"].Error)
|
||||
assert.Empty(t, status.Endpoints["/v1/models"].Error)
|
||||
assert.Equal(t, "response_too_large", status.Endpoints["/metrics"].Error)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = fetchInferenceStatus(ctx, channel)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestParseVLLMMetricsPreservesSeriesAndHistogramTotals(t *testing.T) {
|
||||
metrics, err := parseInferenceMetrics(`# TYPE vllm:num_requests_running gauge
|
||||
vllm:num_requests_running{engine="0",model_name="model,a"} 0
|
||||
vllm:num_requests_running{model_name="model,a",engine="1"} 2
|
||||
# TYPE vllm:time_to_first_token_seconds histogram
|
||||
vllm:time_to_first_token_seconds_bucket{le="+Inf"} 4
|
||||
vllm:time_to_first_token_seconds_sum 10
|
||||
vllm:time_to_first_token_seconds_count 4
|
||||
# TYPE vllm:kv_cache_usage_perc gauge
|
||||
vllm:kv_cache_usage_perc NaN
|
||||
vllm:bad_value +Inf
|
||||
# TYPE process_start_time_seconds gauge
|
||||
process_start_time_seconds 100
|
||||
unrelated_metric 123
|
||||
`, "vllm:")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []inferenceMetric{
|
||||
{Name: "vllm:num_requests_running", Labels: map[string]string{"engine": "0", "model_name": "model,a"}, Value: 0},
|
||||
{Name: "vllm:num_requests_running", Labels: map[string]string{"engine": "1", "model_name": "model,a"}, Value: 2},
|
||||
{Name: "vllm:time_to_first_token_seconds_sum", Labels: map[string]string{}, Value: 10},
|
||||
{Name: "vllm:time_to_first_token_seconds_count", Labels: map[string]string{}, Value: 4},
|
||||
{Name: "process_start_time_seconds", Labels: map[string]string{}, Value: 100},
|
||||
}, metrics)
|
||||
_, err = parseInferenceMetrics("vllm:broken{engine=oops} 1\n", "vllm:")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Opt-in verification against an operator-provided instance; credentials stay in the environment.
|
||||
func TestVLLMStatusLive(t *testing.T) {
|
||||
base := os.Getenv("NEW_API_VLLM_TEST_URL")
|
||||
if base == "" {
|
||||
t.Skip("NEW_API_VLLM_TEST_URL is not configured")
|
||||
}
|
||||
service.InitHttpClient()
|
||||
status, err := fetchInferenceStatus(context.Background(), &model.Channel{
|
||||
Type: constant.ChannelTypeVLLM, BaseURL: &base, Key: os.Getenv("NEW_API_VLLM_TEST_KEY"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for path, endpoint := range status.Endpoints {
|
||||
assert.Equal(t, 200, endpoint.Status, path)
|
||||
assert.Empty(t, endpoint.Error, path)
|
||||
}
|
||||
assert.NotEmpty(t, status.Version)
|
||||
assert.NotEmpty(t, status.Models)
|
||||
assert.NotEmpty(t, status.Metrics)
|
||||
assert.NotEmpty(t, status.RawMetrics)
|
||||
}
|
||||
|
||||
func TestSGLangStatusUsesDocumentedEndpointsAndRedactsServerConfiguration(t *testing.T) {
|
||||
service.InitHttpClient()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/server_info":
|
||||
_, _ = w.Write([]byte(`{"version":"0.5.19","api_key":"server-secret","admin_api_key":"admin-secret","internal_states":[{"private":"private-state"}]}`))
|
||||
case "/v1/models":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"served-model","root":"served-model","max_model_len":32768}]}`))
|
||||
case "/metrics":
|
||||
_, _ = w.Write([]byte("# TYPE sglang:num_running_reqs gauge\nsglang:num_running_reqs{dp_rank=\"0\"} 0\nsglang:num_running_reqs{dp_rank=\"1\"} 2\n# TYPE sglang:time_to_first_token_seconds histogram\nsglang:time_to_first_token_seconds_bucket{le=\"+Inf\"} 2\nsglang:time_to_first_token_seconds_sum 1.5\nsglang:time_to_first_token_seconds_count 2\nvllm:num_requests_running 9\n"))
|
||||
default:
|
||||
t.Errorf("unexpected SGLang path: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
status, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeSGLang, BaseURL: &upstream.URL, Key: "test-key"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.5.19", status.Version)
|
||||
require.Len(t, status.Models, 1)
|
||||
assert.Equal(t, "served-model", status.Models[0].ID)
|
||||
assert.ElementsMatch(t, []inferenceMetric{
|
||||
{Name: "sglang:num_running_reqs", Labels: map[string]string{"dp_rank": "0"}, Value: 0},
|
||||
{Name: "sglang:num_running_reqs", Labels: map[string]string{"dp_rank": "1"}, Value: 2},
|
||||
{Name: "sglang:time_to_first_token_seconds_sum", Labels: map[string]string{}, Value: 1.5},
|
||||
{Name: "sglang:time_to_first_token_seconds_count", Labels: map[string]string{}, Value: 2},
|
||||
}, status.Metrics)
|
||||
encoded, err := common.Marshal(status)
|
||||
require.NoError(t, err)
|
||||
for _, secret := range []string{"test-key", "server-secret", "admin-secret", "private-state", "api_key", "internal_states"} {
|
||||
assert.NotContains(t, string(encoded), secret)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMultiKeyEnableRestoresOnlyExhaustedChannels(t *testing.T) {
|
||||
previousDB, previousLogDB := model.DB, model.LOG_DB
|
||||
previousType, previousLogType := common.MainDatabaseType(), common.LogDatabaseType()
|
||||
previousMaster, previousCache, previousRedis, previousSQLite := common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath
|
||||
t.Cleanup(func() {
|
||||
model.DB, model.LOG_DB = previousDB, previousLogDB
|
||||
common.SetDatabaseTypes(previousType, previousLogType)
|
||||
common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath = previousMaster, previousCache, previousRedis, previousSQLite
|
||||
})
|
||||
t.Setenv("SQL_DSN", os.Getenv("TEST_CHANNEL_SQL_DSN"))
|
||||
t.Setenv("LOG_SQL_DSN", "")
|
||||
common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled = false, false, false
|
||||
common.SQLitePath = filepath.Join(t.TempDir(), "channel.db")
|
||||
require.NoError(t, model.InitDB())
|
||||
database := model.DB
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
||||
model.LOG_DB = database
|
||||
common.SetLogDatabaseType(common.MainDatabaseType())
|
||||
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}, &model.Log{}, &model.AuditLog{}))
|
||||
root := &model.User{Username: "multi-key-review-root", Role: common.RoleRootUser, Status: common.UserStatusEnabled}
|
||||
require.NoError(t, database.Create(root).Error)
|
||||
t.Cleanup(func() { require.NoError(t, database.Unscoped().Delete(root).Error) })
|
||||
versionQuery := "SELECT VERSION()"
|
||||
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
|
||||
versionQuery = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, database.Raw(versionQuery).Scan(&version).Error)
|
||||
t.Logf("database=%s version=%s", common.MainDatabaseType(), version)
|
||||
|
||||
for _, cacheEnabled := range []bool{false, true} {
|
||||
for _, action := range []string{"enable_key", "enable_all_keys"} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
initialStatus int
|
||||
manualOverride string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "key exhaustion restores", initialStatus: common.ChannelStatusEnabled, wantStatus: common.ChannelStatusEnabled},
|
||||
{name: "manual disable is preserved", initialStatus: common.ChannelStatusManuallyDisabled, wantStatus: common.ChannelStatusManuallyDisabled},
|
||||
{name: "manual disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "status", wantStatus: common.ChannelStatusManuallyDisabled},
|
||||
{name: "tag disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "tag", wantStatus: common.ChannelStatusManuallyDisabled},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("cache=%t/%s/%s", cacheEnabled, action, tc.name), func(t *testing.T) {
|
||||
common.MemoryCacheEnabled = cacheEnabled
|
||||
tag := t.Name()
|
||||
channel := &model.Channel{Name: t.Name(), Type: 1, Key: "key-one\nkey-two", Status: tc.initialStatus, Models: "test-model", Group: "default", Tag: &tag,
|
||||
ChannelInfo: model.ChannelInfo{IsMultiKey: true, MultiKeySize: 2, MultiKeyStatusList: map[int]int{1: common.ChannelStatusManuallyDisabled}},
|
||||
}
|
||||
require.NoError(t, channel.Insert())
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, channel.Delete())
|
||||
model.InitChannelCache()
|
||||
})
|
||||
for _, operation := range []string{"disable_key", action} {
|
||||
if operation == action {
|
||||
if tc.manualOverride == "status" {
|
||||
model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "manual operation")
|
||||
} else if tc.manualOverride == "tag" {
|
||||
require.NoError(t, model.DisableChannelByTag(tag))
|
||||
}
|
||||
}
|
||||
payload, err := common.Marshal(MultiKeyManageRequest{ChannelId: channel.Id, Action: operation, KeyIndex: common.GetPointer(0)})
|
||||
require.NoError(t, err)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Set("id", root.Id)
|
||||
c.Set("role", common.RoleRootUser)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/channel/multi_key", bytes.NewReader(payload))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
ManageMultiKeys(c)
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, recorder.Body.String())
|
||||
}
|
||||
loaded, err := model.GetChannelById(channel.Id, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantStatus, loaded.Status)
|
||||
assert.NotContains(t, loaded.ChannelInfo.MultiKeyStatusList, 0)
|
||||
assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledReason, 0)
|
||||
assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledTime, 0)
|
||||
var ability model.Ability
|
||||
require.NoError(t, database.Where("channel_id = ?", channel.Id).First(&ability).Error)
|
||||
assert.Equal(t, tc.wantStatus == common.ChannelStatusEnabled, ability.Enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,25 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -18,7 +30,7 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
|
||||
openaiErr := types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError)
|
||||
|
||||
c := newPinRetryContext()
|
||||
assert.True(t, shouldRetry(c, openaiErr, 1))
|
||||
assert.True(t, service.ShouldRetryRelayError(c, openaiErr, 1))
|
||||
|
||||
origin := newPinRetryContext()
|
||||
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
|
||||
@@ -27,7 +39,7 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
|
||||
Rank: dto.PinRankOriginTask,
|
||||
RetryMode: dto.PinRetrySameChannel,
|
||||
})
|
||||
assert.True(t, shouldRetry(origin, openaiErr, 1), "origin pin retries on the same channel")
|
||||
assert.True(t, service.ShouldRetryRelayError(origin, openaiErr, 1), "origin pin retries on the same channel")
|
||||
|
||||
token := newPinRetryContext()
|
||||
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
|
||||
@@ -36,14 +48,14 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
|
||||
Rank: dto.PinRankToken,
|
||||
RetryMode: dto.PinRetrySingleAttempt,
|
||||
})
|
||||
assert.False(t, shouldRetry(token, openaiErr, 1), "token pin suppresses retry")
|
||||
assert.Equal(t, service.PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}, service.DecideRelayRetry(token, openaiErr, 1), "token pin suppresses retry")
|
||||
}
|
||||
|
||||
func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
|
||||
taskErr := &dto.TaskError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
c := newPinRetryContext()
|
||||
assert.True(t, shouldRetryTaskRelay(c, 1, taskErr, 1))
|
||||
assert.Equal(t, "retry", decideTaskRetry(c, taskErr, 1).Action)
|
||||
|
||||
origin := newPinRetryContext()
|
||||
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
|
||||
@@ -52,7 +64,7 @@ func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
|
||||
Rank: dto.PinRankOriginTask,
|
||||
RetryMode: dto.PinRetrySameChannel,
|
||||
})
|
||||
assert.True(t, shouldRetryTaskRelay(origin, 2, taskErr, 1))
|
||||
assert.Equal(t, "retry", decideTaskRetry(origin, taskErr, 1).Action)
|
||||
|
||||
token := newPinRetryContext()
|
||||
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
|
||||
@@ -61,7 +73,7 @@ func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
|
||||
Rank: dto.PinRankToken,
|
||||
RetryMode: dto.PinRetrySingleAttempt,
|
||||
})
|
||||
assert.False(t, shouldRetryTaskRelay(token, 1, taskErr, 1))
|
||||
assert.Equal(t, service.PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}, decideTaskRetry(token, taskErr, 1))
|
||||
}
|
||||
|
||||
func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) {
|
||||
@@ -84,7 +96,7 @@ func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) {
|
||||
assert.Equal(t, 7, pin.ChannelId)
|
||||
assert.Equal(t, dto.PinRetrySingleAttempt, pin.RetryMode)
|
||||
assert.Empty(t, overridden)
|
||||
assert.False(t, shouldRetry(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1))
|
||||
assert.False(t, service.ShouldRetryRelayError(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1))
|
||||
}
|
||||
|
||||
func newPinRetryContext() *gin.Context {
|
||||
@@ -93,3 +105,115 @@ func newPinRetryContext() *gin.Context {
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRequestPolicyConfigReturnsSettingsWithoutMigration(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
GetRequestPolicy(ctx)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.True(t, response.Success)
|
||||
assert.Contains(t, response.Data, "options")
|
||||
assert.NotContains(t, response.Data, "migration")
|
||||
assert.NotContains(t, response.Data, "differences")
|
||||
}
|
||||
|
||||
func TestRequestPolicyRoutingDatabaseMatrix(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
|
||||
t.Run(dialect.kind, func(t *testing.T) {
|
||||
dsn := ""
|
||||
if dialect.env != "" {
|
||||
dsn = os.Getenv(dialect.env)
|
||||
if dsn == "" {
|
||||
t.Skipf("%s not configured", dialect.env)
|
||||
}
|
||||
}
|
||||
db := modelManagementDB(t, dialect.kind, dsn)
|
||||
previousGroups := setting.UserUsableGroups2JSONString()
|
||||
previousRatios, err := common.Marshal(ratio_setting.GetGroupRatioCopy())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1}`))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(previousGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(string(previousRatios)))
|
||||
})
|
||||
// Channel 1 holds the session binding but is no longer usable.
|
||||
channels := []model.Channel{
|
||||
{Id: 1, Name: "A", Type: 1, Key: "test-only", Status: common.ChannelStatusAutoDisabled, Models: "policy-test", Group: "default", Priority: common.GetPointer(int64(10))},
|
||||
{Id: 2, Name: "B", Type: 1, Key: "test-only", Status: common.ChannelStatusEnabled, Models: "policy-test", Group: "default", Priority: common.GetPointer(int64(5))},
|
||||
}
|
||||
for i := range channels {
|
||||
require.NoError(t, db.Create(&channels[i]).Error)
|
||||
require.NoError(t, channels[i].AddAbilities(db))
|
||||
}
|
||||
affinity := operation_setting.GetChannelAffinitySetting()
|
||||
previousAffinity := *affinity
|
||||
t.Cleanup(func() { *affinity = previousAffinity })
|
||||
for _, cached := range []bool{false, true} {
|
||||
for _, keep := range []bool{false, true} {
|
||||
for _, tc := range []struct {
|
||||
globalMode, ruleMode string
|
||||
blocked bool
|
||||
}{
|
||||
{"strict", "inherit", true},
|
||||
{"prefer", "strict", true},
|
||||
{"prefer", "inherit", false},
|
||||
{"strict", "prefer", false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("cache=%t/keep=%t/global=%s/rule=%s", cached, keep, tc.globalMode, tc.ruleMode), func(t *testing.T) {
|
||||
common.MemoryCacheEnabled = cached
|
||||
model.InitChannelCache()
|
||||
snapshot, err := model.BuildRequestPolicy(map[string]string{
|
||||
"channel_affinity_setting.enabled": "true",
|
||||
"channel_affinity_setting.session_mode": tc.globalMode,
|
||||
"channel_affinity_setting.keep_on_channel_disabled": fmt.Sprint(keep),
|
||||
"channel_affinity_setting.rules": fmt.Sprintf(`[{"name":"session","model_regex":[".*"],"key_sources":[{"type":"request_header","key":"X-Session"}],"session_mode":%q}]`, tc.ruleMode),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
*affinity = snapshot.Affinity
|
||||
seed := newPinRetryContext()
|
||||
seed.Request.Header.Set("X-Session", t.Name())
|
||||
_, found := service.GetPreferredChannelByAffinity(seed, "policy-test", "default")
|
||||
require.False(t, found)
|
||||
seed.Set("channel_id", 1)
|
||||
service.RecordChannelAffinity(seed, 1)
|
||||
t.Cleanup(func() { service.ClearCurrentChannelAffinityCache(seed) })
|
||||
bound, found := service.GetPreferredChannelByAffinity(seed, "policy-test", "default")
|
||||
require.True(t, found)
|
||||
require.Equal(t, 1, bound)
|
||||
|
||||
request := newPinRetryContext()
|
||||
request.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"policy-test"}`))
|
||||
request.Request.Header.Set("Content-Type", "application/json")
|
||||
request.Request.Header.Set("X-Session", t.Name())
|
||||
common.SetContextKey(request, constant.ContextKeyUsingGroup, "default")
|
||||
middleware.Distribute()(request)
|
||||
events := service.RequestPolicy(request).Events()
|
||||
assert.True(t, slices.ContainsFunc(events, func(event service.PolicyEvent) bool { return event.Decision.Reason == "session_rule_matched" }), "decision events are recorded for every request")
|
||||
bound, found = service.GetPreferredChannelByAffinity(seed, "policy-test", "default")
|
||||
if tc.blocked {
|
||||
assert.Equal(t, http.StatusServiceUnavailable, request.Writer.Status())
|
||||
assert.True(t, request.IsAborted())
|
||||
assert.Equal(t, keep, found, "binding retention is independent of strict request handling")
|
||||
if found {
|
||||
assert.Equal(t, 1, bound)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.False(t, request.IsAborted())
|
||||
assert.Equal(t, 2, common.GetContextKeyInt(request, constant.ContextKeyChannelId), "prefer falls back to the next eligible channel")
|
||||
require.True(t, found)
|
||||
assert.Equal(t, 2, bound, "a successful fallback rebinds the session")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||