feat: sync remaining channel and relay updates

This commit is contained in:
OpenClaw Task Bot
2026-04-27 08:56:38 +08:00
parent 0321101ff7
commit 58c0258362
18 changed files with 465 additions and 271 deletions

View File

@@ -75,6 +75,12 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeReplicate
case constant.ChannelTypeCodex:
apiType = constant.APITypeCodex
case constant.ChannelTypeCodexCLIProxy:
apiType = constant.APITypeOpenAI
case constant.ChannelTypeClaudeCodeCLIProxy:
apiType = constant.APITypeAnthropic
case constant.ChannelTypeGeminiCLIProxy:
apiType = constant.APITypeOpenAI
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -24,6 +24,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeImageEdit: {Path: "/v1/images/edits", Method: "POST"},
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
}

View File

@@ -19,6 +19,7 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
case constant.ChannelTypeAws:
fallthrough
case constant.ChannelTypeAnthropic:
case constant.ChannelTypeClaudeCodeCLIProxy:
endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}
case constant.ChannelTypeVertexAi:
fallthrough

View File

@@ -55,6 +55,9 @@ const (
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeCodex = 57
ChannelTypeCodexCLIProxy = 58
ChannelTypeClaudeCodeCLIProxy = 59
ChannelTypeGeminiCLIProxy = 60
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -118,6 +121,9 @@ var ChannelBaseURLs = []string{
"https://api.openai.com", //55
"https://api.replicate.com", //56
"https://chatgpt.com", //57
"", //58
"", //59
"", //60
}
var ChannelTypeNames = map[int]string{
@@ -175,6 +181,9 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "Codex",
ChannelTypeCodexCLIProxy: "CodexCLIProxy",
ChannelTypeClaudeCodeCLIProxy: "ClaudeCodeCLIProxy",
ChannelTypeGeminiCLIProxy: "GeminiCLIProxy",
}
func GetChannelTypeName(channelType int) string {

View File

@@ -10,6 +10,7 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeImageEdit EndpointType = "image-edit"
EndpointTypeEmbeddings EndpointType = "embeddings"
EndpointTypeOpenAIVideo EndpointType = "openai-video"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"

View File

@@ -136,6 +136,14 @@ func GetClaudeAuthHeader(token string) http.Header {
return h
}
func GetClaudeCodeAuthHeader(token string) http.Header {
h := http.Header{}
h.Add("Authorization", fmt.Sprintf("Bearer %s", token))
h.Add("x-api-key", token)
h.Add("anthropic-version", "2023-06-01")
return h
}
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {

View File

@@ -2,13 +2,16 @@ package controller
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"strconv"
"strings"
@@ -42,6 +45,56 @@ type testResult struct {
newAPIError *types.NewAPIError
}
const imageEditTestPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn7Zc8AAAAASUVORK5CYII="
func buildImageEditTestMultipartRequest(requestPath, model string) (*http.Request, error) {
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
if err := writer.WriteField("model", model); err != nil {
return nil, err
}
if err := writer.WriteField("prompt", "edit this image"); err != nil {
return nil, err
}
if err := writer.WriteField("size", "1024x1024"); err != nil {
return nil, err
}
if err := writer.WriteField("n", "1"); err != nil {
return nil, err
}
imageBytes, err := base64.StdEncoding.DecodeString(imageEditTestPngBase64)
if err != nil {
return nil, err
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="image"; filename="channel-test.png"`)
header.Set("Content-Type", "image/png")
part, err := writer.CreatePart(header)
if err != nil {
return nil, err
}
if _, err = part.Write(imageBytes); err != nil {
return nil, err
}
if err = writer.Close(); err != nil {
return nil, err
}
req := &http.Request{
Method: "POST",
URL: &url.URL{Path: requestPath},
Body: io.NopCloser(bytes.NewReader(requestBody.Bytes())),
Header: make(http.Header),
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.ContentLength = int64(requestBody.Len())
return req, nil
}
func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string {
normalized := strings.TrimSpace(endpointType)
if normalized != "" {
@@ -153,6 +206,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
if requestPath == "/v1/images/edits" {
c.Request, err = buildImageEditTestMultipartRequest(requestPath, testModel)
if err != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeReadRequestBodyFailed),
}
}
}
c.Set("channel", channel.Type)
c.Set("base_url", channel.GetBaseURL())
group, _ := model.GetUserGroup(1, false)
@@ -184,7 +247,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
relayFormat = types.RelayFormatGemini
case constant.EndpointTypeJinaRerank:
relayFormat = types.RelayFormatRerank
case constant.EndpointTypeImageGeneration:
case constant.EndpointTypeImageGeneration, constant.EndpointTypeImageEdit:
relayFormat = types.RelayFormatOpenAIImage
case constant.EndpointTypeEmbeddings:
relayFormat = types.RelayFormatEmbedding
@@ -200,6 +263,9 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
if c.Request.URL.Path == "/v1/images/edits" {
relayFormat = types.RelayFormatOpenAIImage
}
if c.Request.URL.Path == "/v1/messages" {
relayFormat = types.RelayFormatClaude
}
@@ -294,7 +360,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeImagesGenerations:
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
// 图像生成请求 - request 已经是正确的类型
if imageReq, ok := request.(*dto.ImageRequest); ok {
convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
@@ -366,44 +432,53 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
}
}
jsonData, err := common.Marshal(convertedRequest)
if err != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
}
}
//jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
//if err != nil {
// return testResult{
// context: c,
// localErr: err,
// newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
// }
//}
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil {
if fixedErr, ok := relaycommon.AsParamOverrideReturnError(err); ok {
return testResult{
context: c,
localErr: fixedErr,
newAPIError: relaycommon.NewAPIErrorFromParamOverride(fixedErr),
}
}
var requestBody *bytes.Buffer
switch req := convertedRequest.(type) {
case *bytes.Buffer:
requestBody = bytes.NewBuffer(req.Bytes())
case bytes.Buffer:
requestBody = bytes.NewBuffer(req.Bytes())
default:
jsonData, marshalErr := common.Marshal(convertedRequest)
if marshalErr != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid),
localErr: marshalErr,
newAPIError: types.NewError(marshalErr, types.ErrorCodeJsonMarshalFailed),
}
}
//jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
//if err != nil {
// return testResult{
// context: c,
// localErr: err,
// newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
// }
//}
if len(info.ParamOverride) > 0 {
jsonData, marshalErr = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if marshalErr != nil {
if fixedErr, ok := relaycommon.AsParamOverrideReturnError(marshalErr); ok {
return testResult{
context: c,
localErr: fixedErr,
newAPIError: relaycommon.NewAPIErrorFromParamOverride(fixedErr),
}
}
return testResult{
context: c,
localErr: marshalErr,
newAPIError: types.NewError(marshalErr, types.ErrorCodeChannelParamOverrideInvalid),
}
}
}
requestBody = bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
}
requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil {
return testResult{
@@ -618,6 +693,14 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
N: lo.ToPtr(uint(1)),
Size: "1024x1024",
}
case constant.EndpointTypeImageEdit:
// 返回 ImageRequest
return &dto.ImageRequest{
Model: model,
Prompt: "edit this image",
N: lo.ToPtr(uint(1)),
Size: "1024x1024",
}
case constant.EndpointTypeJinaRerank:
// 返回 RerankRequest
return &dto.RerankRequest{

View File

@@ -178,6 +178,8 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
switch channel.Type {
case constant.ChannelTypeAnthropic:
headers = GetClaudeAuthHeader(key)
case constant.ChannelTypeClaudeCodeCLIProxy:
headers = GetClaudeCodeAuthHeader(key)
default:
headers = GetAuthHeader(key)
}
@@ -469,6 +471,12 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
}
if (channel.Type == constant.ChannelTypeCodexCLIProxy ||
channel.Type == constant.ChannelTypeClaudeCodeCLIProxy ||
channel.Type == constant.ChannelTypeGeminiCLIProxy) && strings.TrimSpace(channel.GetBaseURL()) == "" {
return fmt.Errorf("proxy-compatible channel requires base_url")
}
// Codex OAuth key validation (optional, only when JSON object is provided)
if channel.Type == constant.ChannelTypeCodex {
trimmedKey := strings.TrimSpace(channel.Key)

View File

@@ -291,6 +291,8 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
} else {
url = fmt.Sprintf("%s/v1/models", baseURL)
}
case constant.ChannelTypeCodexCLIProxy, constant.ChannelTypeGeminiCLIProxy:
url = fmt.Sprintf("%s/models", baseURL)
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}

View File

@@ -1,96 +1,27 @@
# New-API Docker Compose Configuration
#
# Quick Start:
# 1. docker-compose up -d
# 2. Access at http://localhost:3000
#
# Using MySQL instead of PostgreSQL:
# 1. Comment out the postgres service and SQL_DSN line 15
# 2. Uncomment the mysql service and SQL_DSN line 16
# 3. Uncomment mysql in depends_on (line 28)
# 4. Uncomment mysql_data in volumes section (line 64)
#
# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
version: '3.4' # For compatibility with older Docker versions
version: '3.4'
services:
new-api:
image: calciumion/new-api:latest
build:
context: .
dockerfile: Dockerfile
image: local/new-api:hk88-src-20260416-173717
container_name: new-api
restart: always
command: --log-dir /app/logs
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/data
- ./logs:/app/logs
- /docker/new-api/data:/data
environment:
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
- REDIS_CONN_STRING=redis://redis
- SQL_DSN=new_api:Ll@12331100@tcp(hdy-hk-8-8.311169.xyz:3306)/new_api
- REDIS_CONN_STRING=redis://:Ll12331100@hdy-hk-8-8.311169.xyz:6379
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间单位秒默认120秒如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL默认为官方地址 (Umami Script URL, defaults to official URL)
depends_on:
- redis
- postgres
# - mysql # Uncomment if using MySQL
- SESSION_SECRET=ai-ikmkj-session-20260317
- CRYPTO_SECRET=ai-ikmkj-crypto-20260317
networks:
- new-api-network
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:latest
container_name: redis
restart: always
networks:
- new-api-network
postgres:
image: postgres:15
container_name: postgres
restart: always
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
POSTGRES_DB: new-api
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- new-api-network
# ports:
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
# mysql:
# image: mysql:8.2
# container_name: mysql
# restart: always
# environment:
# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
# MYSQL_DATABASE: new-api
# volumes:
# - mysql_data:/var/lib/mysql
# networks:
# - new-api-network
# ports:
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
volumes:
pg_data:
# mysql_data:
- default
- biji-houduan_biji-net
networks:
new-api-network:
driver: bridge
biji-houduan_biji-net:
external: true

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -81,7 +82,12 @@ func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relayc
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("x-api-key", info.ApiKey)
if info.ChannelType == constant.ChannelTypeClaudeCodeCLIProxy {
req.Set("Authorization", "Bearer "+info.ApiKey)
req.Set("x-api-key", info.ApiKey)
} else {
req.Set("x-api-key", info.ApiKey)
}
anthropicVersion := c.Request.Header.Get("anthropic-version")
if anthropicVersion == "" {
anthropicVersion = "2023-06-01"

View File

@@ -301,24 +301,27 @@ func (info *RelayInfo) ToString() string {
// 定义支持流式选项的通道类型
var streamSupportedChannels = map[int]bool{
constant.ChannelTypeOpenAI: true,
constant.ChannelTypeAnthropic: true,
constant.ChannelTypeAws: true,
constant.ChannelTypeGemini: true,
constant.ChannelCloudflare: true,
constant.ChannelTypeAzure: true,
constant.ChannelTypeVolcEngine: true,
constant.ChannelTypeOllama: true,
constant.ChannelTypeXai: true,
constant.ChannelTypeDeepSeek: true,
constant.ChannelTypeBaiduV2: true,
constant.ChannelTypeZhipu_v4: true,
constant.ChannelTypeAli: true,
constant.ChannelTypeSubmodel: true,
constant.ChannelTypeCodex: true,
constant.ChannelTypeMoonshot: true,
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
constant.ChannelTypeOpenAI: true,
constant.ChannelTypeCodexCLIProxy: true,
constant.ChannelTypeGeminiCLIProxy: true,
constant.ChannelTypeAnthropic: true,
constant.ChannelTypeClaudeCodeCLIProxy: true,
constant.ChannelTypeAws: true,
constant.ChannelTypeGemini: true,
constant.ChannelCloudflare: true,
constant.ChannelTypeAzure: true,
constant.ChannelTypeVolcEngine: true,
constant.ChannelTypeOllama: true,
constant.ChannelTypeXai: true,
constant.ChannelTypeDeepSeek: true,
constant.ChannelTypeBaiduV2: true,
constant.ChannelTypeZhipu_v4: true,
constant.ChannelTypeAli: true,
constant.ChannelTypeSubmodel: true,
constant.ChannelTypeCodex: true,
constant.ChannelTypeMoonshot: true,
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
}
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {

View File

@@ -33,6 +33,15 @@ func GetFullRequestURL(baseURL string, requestURL string, channelType int) strin
fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments"))
}
}
if channelType == constant.ChannelTypeCodexCLIProxy ||
channelType == constant.ChannelTypeGeminiCLIProxy {
trimmedRequestURL := strings.TrimPrefix(requestURL, "/v1")
if trimmedRequestURL == "" {
trimmedRequestURL = "/"
}
fullRequestURL = fmt.Sprintf("%s%s", baseURL, trimmedRequestURL)
}
return fullRequestURL
}

View File

@@ -27,7 +27,10 @@ import {
verifyJSON,
} from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { CHANNEL_OPTIONS, MODEL_FETCHABLE_CHANNEL_TYPES } from '../../../../constants';
import {
CHANNEL_OPTIONS,
MODEL_FETCHABLE_CHANNEL_TYPES,
} from '../../../../constants';
import {
SideSheet,
Space,
@@ -128,7 +131,7 @@ const DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL = 'doubao-coding-plan';
// 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, 58, 59, 60,
]);
function type2secretPrompt(type) {
@@ -277,7 +280,8 @@ const EditChannelModal = (props) => {
[inputs.upstream_model_update_last_detected_models],
);
const upstreamDetectedModelsPreview = useMemo(
() => upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT),
() =>
upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT),
[upstreamDetectedModels],
);
const upstreamDetectedModelsOmittedCount =
@@ -310,9 +314,7 @@ const EditChannelModal = (props) => {
return {
tagLabel: t('不更改'),
tagColor: 'grey',
preview: t(
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
),
preview: t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数'),
};
}
if (!verifyJSON(raw)) {
@@ -1612,7 +1614,7 @@ const EditChannelModal = (props) => {
return;
}
if (
localInputs.type === 45 &&
[45, 58, 59, 60].includes(localInputs.type) &&
(!localInputs.base_url || localInputs.base_url.trim() === '')
) {
showInfo(t('请输入API地址'));
@@ -2257,6 +2259,39 @@ const EditChannelModal = (props) => {
/>
)}
{inputs.type === 58 && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t(
'该类型用于兼容上游把 OpenAI 官方兼容入口放在 /openai 的第三方站点。这里填写上游给官方 OpenAI 客户端或 Codex CLI 使用的 Base URL例如https://api.univibe.cc/openai下游客户端仍然使用你自己的 New API 地址加 /v1。',
)}
/>
)}
{inputs.type === 59 && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t(
'该类型用于兼容 Claude Code 或 Anthropic 网关类上游。这里填写上游提供的 Anthropic Base URL例如https://gateway.example.com/anthropic转发时会保持 Anthropic 的 /v1/messages 路径,但按网关常见要求使用 Bearer 认证。',
)}
/>
)}
{inputs.type === 60 && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t(
'该类型用于兼容 Gemini OpenAI 兼容入口。这里填写上游给 Gemini CLI 或 OpenAI 兼容 SDK 使用的 Base URL例如https://generativelanguage.googleapis.com/v1beta/openai下游客户端仍然使用你自己的 New API 地址加 /v1。',
)}
/>
)}
{inputs.type === 20 && (
<Form.Switch
field='is_enterprise_account'
@@ -3001,6 +3036,9 @@ const EditChannelModal = (props) => {
inputs.type !== 8 &&
inputs.type !== 22 &&
inputs.type !== 36 &&
inputs.type !== 58 &&
inputs.type !== 59 &&
inputs.type !== 60 &&
(inputs.type !== 45 || doubaoApiEditUnlocked) && (
<div>
<Form.Input
@@ -3057,6 +3095,66 @@ const EditChannelModal = (props) => {
</div>
)}
{inputs.type === 58 && (
<div>
<Form.Input
field='base_url'
label={t('上游 OpenAI / Codex Base URL')}
placeholder={t(
'请输入上游提供给官方 OpenAI 客户端或 Codex CLI 使用的 Base URL例如https://api.univibe.cc/openai',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
extraText={t(
'此类型会把下游的 /v1/* 请求自动映射到上游的 /openai/*。这里不要填写 /v1保持上游原始 Base URL 即可。',
)}
/>
</div>
)}
{inputs.type === 59 && (
<div>
<Form.Input
field='base_url'
label={t('上游 Claude Code / Anthropic Base URL')}
placeholder={t(
'请输入上游提供给 Claude Code 使用的 Anthropic Base URL例如https://gateway.example.com/anthropic',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
extraText={t(
'此类型保持 Anthropic 官方请求路径,不自动补 /v1 到 Base URL这里直接填写上游原始 Base URL 即可。',
)}
/>
</div>
)}
{inputs.type === 60 && (
<div>
<Form.Input
field='base_url'
label={t('上游 Gemini OpenAI Compat Base URL')}
placeholder={t(
'请输入 Gemini OpenAI 兼容 Base URL例如https://generativelanguage.googleapis.com/v1beta/openai',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
extraText={t(
'此类型会把下游的 /v1/* 请求自动映射到上游的 /v1beta/openai/*。这里不要再额外填写 /v1。',
)}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
@@ -3478,79 +3576,81 @@ const EditChannelModal = (props) => {
/>
<Form.Switch
field='upstream_model_update_auto_sync_enabled'
label={t('是否自动同步上游模型更新')}
checkedText={t('开')}
uncheckedText={t('关')}
disabled={!inputs.upstream_model_update_check_enabled}
onChange={(value) =>
handleChannelOtherSettingsChange(
'upstream_model_update_auto_sync_enabled',
value,
)
}
extraText={t(
'开启后检测到新增模型会自动加入当前渠道模型列表',
)}
field='upstream_model_update_auto_sync_enabled'
label={t('是否自动同步上游模型更新')}
checkedText={t('开')}
uncheckedText={t('关')}
disabled={!inputs.upstream_model_update_check_enabled}
onChange={(value) =>
handleChannelOtherSettingsChange(
'upstream_model_update_auto_sync_enabled',
value,
)
}
extraText={t(
'开启后检测到新增模型会自动加入当前渠道模型列表',
)}
/>
<div className='text-xs text-gray-500 mb-3'>
{t('上次检测到可加入模型')}:&nbsp;
{upstreamDetectedModels.length === 0 ? (
t('暂无')
t('暂无')
) : (
<>
<Tooltip
position='topLeft'
content={
<div className='max-w-[640px] break-all text-xs leading-5'>
{upstreamDetectedModels.join(', ')}
</div>
}
>
<>
<Tooltip
position='topLeft'
content={
<div className='max-w-[640px] break-all text-xs leading-5'>
{upstreamDetectedModels.join(', ')}
</div>
}
>
<span className='cursor-help break-all'>
{upstreamDetectedModelsPreview.join(', ')}
</span>
</Tooltip>
<span className='ml-1 text-gray-400'>
</Tooltip>
<span className='ml-1 text-gray-400'>
{upstreamDetectedModelsOmittedCount > 0
? t('(共 {{total}} 个,省略 {{omit}} 个)', {
? t('(共 {{total}} 个,省略 {{omit}} 个)', {
total: upstreamDetectedModels.length,
omit: upstreamDetectedModelsOmittedCount,
})
: t('(共 {{total}} 个)', {
: t('(共 {{total}} 个)', {
total: upstreamDetectedModels.length,
})}
</span>
</>
</>
)}
</div>
<div className='mb-4'>
<div className='flex items-center justify-between gap-2 mb-1'>
<Text className='text-sm font-medium'>{t('参数覆盖')}</Text>
<Text className='text-sm font-medium'>
{t('参数覆盖')}
</Text>
<Space wrap>
<Button
size='small'
type='primary'
icon={<IconCode size={14} />}
onClick={() => setParamOverrideEditorVisible(true)}
size='small'
type='primary'
icon={<IconCode size={14} />}
onClick={() => setParamOverrideEditorVisible(true)}
>
{t('可视化编辑')}
</Button>
<Button
size='small'
onClick={() =>
applyParamOverrideTemplate('operations', 'fill')
}
size='small'
onClick={() =>
applyParamOverrideTemplate('operations', 'fill')
}
>
{t('填充新模板')}
</Button>
<Button
size='small'
onClick={() =>
applyParamOverrideTemplate('legacy', 'fill')
}
size='small'
onClick={() =>
applyParamOverrideTemplate('legacy', 'fill')
}
>
{t('填充旧模板')}
</Button>
@@ -3564,14 +3664,16 @@ const EditChannelModal = (props) => {
</Space>
</div>
<Text type='tertiary' size='small'>
{t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')}
{t(
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
)}
</Text>
<div
className='mt-2 rounded-xl p-3'
style={{
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-fill-2)',
}}
className='mt-2 rounded-xl p-3'
style={{
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-fill-2)',
}}
>
<div className='flex items-center justify-between mb-2'>
<Tag color={paramOverrideMeta.tagColor}>
@@ -3579,17 +3681,19 @@ const EditChannelModal = (props) => {
</Tag>
<Space spacing={8}>
<Button
size='small'
icon={<IconCopy />}
type='tertiary'
onClick={copyParamOverrideJson}
size='small'
icon={<IconCopy />}
type='tertiary'
onClick={copyParamOverrideJson}
>
{t('复制')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setParamOverrideEditorVisible(true)}
size='small'
type='tertiary'
onClick={() =>
setParamOverrideEditorVisible(true)
}
>
{t('编辑')}
</Button>
@@ -3602,80 +3706,80 @@ const EditChannelModal = (props) => {
</div>
<Form.TextArea
field='header_override'
label={t('请求头覆盖')}
placeholder={
t('此项可选,用于覆盖请求头参数') +
'\n' +
t('格式示例:') +
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
}
autosize
onChange={(value) =>
handleInputChange('header_override', value)
}
extraText={
<div className='flex flex-col gap-1'>
<div className='flex gap-2 flex-wrap items-center'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'*': true,
're:^X-Trace-.*$': true,
'X-Foo': '{client_header:X-Foo}',
Authorization: 'Bearer {api_key}',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
},
null,
2,
),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'*': true,
},
null,
2,
),
)
}
>
{t('填入透传模版')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => formatJsonField('header_override')}
>
{t('格式化')}
</Text>
</div>
<div>
<Text type='tertiary' size='small'>
{t('支持变量:')}
</Text>
<div className='text-xs text-tertiary ml-2'>
<div>
{t('渠道密钥')}: {'{api_key}'}
</div>
field='header_override'
label={t('请求头覆盖')}
placeholder={
t('此项可选,用于覆盖请求头参数') +
'\n' +
t('格式示例:') +
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
}
autosize
onChange={(value) =>
handleInputChange('header_override', value)
}
extraText={
<div className='flex flex-col gap-1'>
<div className='flex gap-2 flex-wrap items-center'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'*': true,
're:^X-Trace-.*$': true,
'X-Foo': '{client_header:X-Foo}',
Authorization: 'Bearer {api_key}',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
},
null,
2,
),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'*': true,
},
null,
2,
),
)
}
>
{t('填入透传模版')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => formatJsonField('header_override')}
>
{t('格式化')}
</Text>
</div>
<div>
<Text type='tertiary' size='small'>
{t('支持变量:')}
</Text>
<div className='text-xs text-tertiary ml-2'>
<div>
{t('渠道密钥')}: {'{api_key}'}
</div>
</div>
</div>
}
showClear
</div>
}
showClear
/>
<JSONEditor
key={`status_code_mapping-${isEdit ? channelId : 'new'}`}

View File

@@ -60,6 +60,7 @@ const ModelTestModal = ({
const streamToggleDisabled = [
'embeddings',
'image-generation',
'image-edit',
'jina-rerank',
'openai-response-compact',
].includes(selectedEndpointType);
@@ -96,6 +97,10 @@ const ModelTestModal = ({
value: 'image-generation',
label: t('图像生成') + ' (/v1/images/generations)',
},
{
value: 'image-edit',
label: 'OpenAI Image Edit (/v1/images/edits)',
},
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
];

View File

@@ -50,6 +50,7 @@ const ENDPOINT_TEMPLATE = {
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
'image-edit': { path: '/v1/images/edits', method: 'POST' },
};
const nameRuleOptions = [

View File

@@ -48,6 +48,7 @@ const ENDPOINT_TEMPLATE = {
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
'image-edit': { path: '/v1/images/edits', method: 'POST' },
};
const EditPrefillGroupModal = ({

View File

@@ -189,11 +189,26 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: 'Codex (OpenAI OAuth)',
},
{
value: 58,
color: 'cyan',
label: 'OpenAI Official / Codex CLI (/openai BaseURL)',
},
{
value: 59,
color: 'indigo',
label: 'Claude Code / Anthropic Gateway',
},
{
value: 60,
color: 'green',
label: 'Gemini CLI / OpenAI Compat',
},
];
// Channel types that support upstream model list fetching in UI.
export const MODEL_FETCHABLE_CHANNEL_TYPES = new Set([
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, 58, 59, 60,
]);
export const MODEL_TABLE_PAGE_SIZE = 10;