Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeSub2API
case constant.ChannelTypeNewAPI:
apiType = constant.APITypeNewAPI
case constant.ChannelTypeGondola:
apiType = constant.APITypeGondola
}
if apiType == -1 {
return constant.APITypeOpenAI, false
Expand Down
2 changes: 2 additions & 0 deletions common/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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.ChannelTypeGondola: // Gondola serves a native Anthropic Messages endpoint alongside the OpenAI one
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeAnthropic}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI:
Expand Down
1 change: 1 addition & 0 deletions constant/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ const (
APITypeAdvancedCustom
APITypeSub2API
APITypeNewAPI
APITypeGondola
APITypeDummy // this one is only for count, do not add any channel after this
)
3 changes: 3 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const (
ChannelTypeAdvancedCustom = 58
ChannelTypeSub2API = 59
ChannelTypeNewAPI = 60
ChannelTypeGondola = 61
ChannelTypeDummy // this one is only for count, do not add any channel after this

)
Expand Down Expand Up @@ -124,6 +125,7 @@ var ChannelBaseURLs = []string{
"", //58
"", //59
"", //60
"https://api.gondola-ai.com", //61
}

var ChannelTypeNames = map[int]string{
Expand Down Expand Up @@ -184,6 +186,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeAdvancedCustom: "Advanced Custom",
ChannelTypeSub2API: "Sub2API",
ChannelTypeNewAPI: "New API",
ChannelTypeGondola: "Gondola",
}

func GetChannelTypeName(channelType int) string {
Expand Down
99 changes: 99 additions & 0 deletions relay/channel/gondola/adaptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package gondola

import (
"errors"
"fmt"
"io"
"net/http"

"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"

"github.com/gin-gonic/gin"
)

type Adaptor struct {
}

func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}

// GetRequestURL Gondola is path-compatible with OpenAI under /v1 and additionally
// serves a native Anthropic Messages endpoint at /v1/messages, so Claude-format
// requests are passed through instead of being converted.
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayFormat == types.RelayFormatClaude {
return fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl), nil
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}

func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey))
return nil
}

func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}

func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, request)
}

func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertImageRequest(c, info, request)
}

func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
return nil, errors.New("not implemented")
}

func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("not implemented")
}

func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not supported")
}

func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, errors.New("not supported")
}

func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return nil, errors.New("not supported")
}

func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
}

func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayFormat {
case types.RelayFormatClaude:
adaptor := claude.Adaptor{}
return adaptor.DoResponse(c, resp, info)
default:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
}
}

func (a *Adaptor) GetModelList() []string {
return ModelList
}

func (a *Adaptor) GetChannelName() string {
return ChannelName
}
15 changes: 15 additions & 0 deletions relay/channel/gondola/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package gondola

// ModelList is a seed list of commonly used Gondola models. Gondola is a
// marketplace, so its catalog changes over time; the full, always-current list
// is available without authentication at https://api.gondola-ai.com/v1/models
// and can be pulled into a channel with the "Fetch Models" button.
var ModelList = []string{
"deepseek-v4-flash",
"claude-sonnet-5",
"kimi-k2-7-code",
"hermes-3-llama-3.1-405b",
"venice-uncensored-1-2",
}

var ChannelName = "gondola"
1 change: 1 addition & 0 deletions relay/common/relay_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ var streamSupportedChannels = map[int]bool{
constant.ChannelTypeAdvancedCustom: true,
constant.ChannelTypeSub2API: true,
constant.ChannelTypeNewAPI: true,
constant.ChannelTypeGondola: true,
constant.ChannelTypeTencent: true,
}

Expand Down
3 changes: 3 additions & 0 deletions relay/relay_adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/jimeng"
"github.com/QuantumNous/new-api/relay/channel/jina"
"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/gondola"
"github.com/QuantumNous/new-api/relay/channel/mistral"
"github.com/QuantumNous/new-api/relay/channel/mokaai"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
Expand Down Expand Up @@ -129,6 +130,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &sub2api.Adaptor{}
case constant.APITypeNewAPI:
return &newapi.Adaptor{}
case constant.APITypeGondola:
return &gondola.Adaptor{}
}
return nil
}
Expand Down
46 changes: 46 additions & 0 deletions web/src/assets/custom/icon-gondola.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import type { SVGProps } from 'react'

type IconGondolaProps = SVGProps<SVGSVGElement> & {
size?: number
}

export function IconGondola({ size = 20, ...props }: IconGondolaProps) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 32 32'
width={size}
height={size}
fill='none'
stroke='#c9a96a'
strokeLinecap='round'
{...props}
Comment on lines +27 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the custom SVG accessible and theme-aware.

IconGondola has no accessible name and no aria-hidden attribute. Mark it aria-hidden='true' when it is decorative. If it can be the only channel identifier, provide an accessible name.

stroke='#c9a96a' bypasses the theme system. Use a theme CSS variable or currentColor with theme-controlled styling.

As per coding guidelines, “装饰性图标使用 aria-hidden="true"” and “主题使用 CSS 变量与 dark:”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/assets/custom/icon-gondola.tsx` around lines 27 - 35, Update
IconGondola’s SVG in the custom icon component to be accessible and theme-aware:
add an explicit aria-hidden="true" for decorative usage, or provide an
accessible name on the IconGondola wrapper/path when it serves as the only
identifier. Replace the hard-coded stroke color in the svg element with
currentColor or a theme CSS variable so styling follows the active theme, and
keep the change localized to the IconGondola component and its svg props.

Source: Coding guidelines

>
<path d='M2 21c4 3 9 4.5 14 4.5S26 24 30 21' strokeWidth='1.4' />
<path d='M2 21c0-2 1-3 2-3.5' strokeWidth='1.4' />
<path d='M30 21c0-2-1-3-2-3.5' strokeWidth='1.4' />
<path d='M5 18.5h22' strokeWidth='1' opacity='0.6' />
<path d='M2.5 18.5l-1.2-4 2.5.8' strokeWidth='1.2' />
<path d='M17 17l8-9' strokeWidth='1.2' opacity='0.7' />
<path d='M6 28c3-1.5 5-1.5 8 0s5 1.5 8 0' strokeWidth='0.8' opacity='0.35' />
</svg>
)
}
12 changes: 8 additions & 4 deletions web/src/features/channels/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ For commercial licensing, please contact support@quantumnous.com

export const CHANNEL_TYPE_NEW_API = 60

export const CHANNEL_TYPE_GONDOLA = 61

export const CHANNEL_TYPES = {
0: 'Unknown',
1: 'OpenAI',
Expand Down Expand Up @@ -81,12 +83,13 @@ export const CHANNEL_TYPES = {
58: 'Advanced Custom',
59: 'Sub2API',
60: 'New API',
61: 'Gondola',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move Gondola display strings to i18n resources.

The new frontend literals bypass i18next. Store semantic translation keys and resolve them with t() where the UI displays them.

  • web/src/features/channels/constants.ts#L86-L86: replace the Gondola display literal with an i18n-backed label.
  • web/src/features/channels/constants.ts#L409-L409: store a semantic API-key prompt key and translate it at the display boundary.
  • web/src/features/channels/lib/channel-type-config.ts#L173-L174: replace the key and model hint literals with translation keys.
  • web/src/features/channels/lib/__tests__/gondola-channel.test.ts#L48-L68: assert translation-key semantics or output under a fixed test locale.

As per coding guidelines, “面向用户的文案必须使用 i18n” and “常量中的消息和状态 label 应保存翻译键,并在展示时通过 t() 翻译”.

📍 Affects 3 files
  • web/src/features/channels/constants.ts#L86-L86 (this comment)
  • web/src/features/channels/constants.ts#L409-L409
  • web/src/features/channels/lib/channel-type-config.ts#L173-L174
  • web/src/features/channels/lib/__tests__/gondola-channel.test.ts#L48-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/features/channels/constants.ts` at line 86, Replace the Gondola
display literal in web/src/features/channels/constants.ts:86 with a semantic
translation key and resolve it with t() at the UI display boundary; apply the
same pattern to the API-key prompt at
web/src/features/channels/constants.ts:409. In
web/src/features/channels/lib/channel-type-config.ts:173-174, store translation
keys for the key and model-hint labels and translate them where rendered. Update
web/src/features/channels/lib/__tests__/gondola-channel.test.ts:48-68 to assert
the translation-key semantics or rendered output under a fixed locale.

Source: Coding guidelines

} as const

const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15,
46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2,
5, 36, 50, 51, 52, 53, 54, 55, 56,
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 42, 34, 20, 61, 4, 40, 27, 25, 17, 26,
15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44,
2, 5, 36, 50, 51, 52, 53, 54, 55, 56,
]

export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
Expand Down Expand Up @@ -389,7 +392,7 @@ export const FIELD_DESCRIPTIONS = {

export const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
59, 60,
59, 60, 61,
])

export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
Expand All @@ -403,6 +406,7 @@ export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
59: 'Enter API key for this channel',
60: 'Enter API key for this channel',
61: 'Format: Gondola API key, starts with gnd_',
}

export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
Expand Down
79 changes: 79 additions & 0 deletions web/src/features/channels/lib/__tests__/gondola-channel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'

import {
CHANNEL_TYPE_GONDOLA,
CHANNEL_TYPE_OPTIONS,
MODEL_FETCHABLE_TYPES,
} from '../../constants'
import { CHANNEL_FORM_DEFAULT_VALUES, channelFormSchema } from '../channel-form'
import { getChannelTypeConfig } from '../channel-type-config'
import { getChannelTypeIcon, getKeyPromptForType } from '../channel-utils'

function gondolaForm(baseUrl: string) {
return {
...CHANNEL_FORM_DEFAULT_VALUES,
name: 'Gondola upstream',
type: CHANNEL_TYPE_GONDOLA,
base_url: baseUrl,
key: 'gnd_test_key',
models: 'deepseek-v4-flash',
}
}

describe('Gondola channel', () => {
test('registers selection, ordering, model discovery, and icon metadata', () => {
const option = CHANNEL_TYPE_OPTIONS.find(
(item) => item.value === CHANNEL_TYPE_GONDOLA
)

assert.deepEqual(option, {
value: CHANNEL_TYPE_GONDOLA,
label: 'Gondola',
})
assert.equal(
CHANNEL_TYPE_OPTIONS.findIndex(
(item) => item.value === CHANNEL_TYPE_GONDOLA
),
CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 20) + 1
)
assert.equal(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_GONDOLA), true)
assert.equal(getChannelTypeIcon(CHANNEL_TYPE_GONDOLA), 'Gondola')
assert.equal(
getKeyPromptForType(CHANNEL_TYPE_GONDOLA),
'Format: Gondola API key, starts with gnd_'
)
assert.equal(getChannelTypeConfig(CHANNEL_TYPE_GONDOLA).icon, 'Gondola')
assert.equal(
getChannelTypeConfig(CHANNEL_TYPE_GONDOLA).defaultBaseUrl,
'https://api.gondola-ai.com'
)
})

test('does not require an explicit Base URL', () => {
assert.equal(channelFormSchema.safeParse(gondolaForm('')).success, true)
assert.equal(
channelFormSchema.safeParse(gondolaForm('https://api.gondola-ai.com'))
.success,
true
)
})
})
10 changes: 10 additions & 0 deletions web/src/features/channels/lib/channel-type-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
models: 'Models',
},
},
61: {
id: 61,
name: CHANNEL_TYPES[61],
icon: 'Gondola',
defaultBaseUrl: 'https://api.gondola-ai.com',
hints: {
key: 'Gondola API Key, starts with gnd_',
models: 'Models fetched from upstream /v1/models',
},
},
}

/**
Expand Down
1 change: 1 addition & 0 deletions web/src/features/channels/lib/channel-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function getChannelTypeIcon(type: number): string {
58: 'NewAPI', // Advanced Custom
59: 'Sub2API', // Sub2API
60: 'NewAPI', // New API
61: 'Gondola', // Gondola
3: 'Azure', // Azure

// Anthropic
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/lobe-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ For commercial licensing, please contact support@quantumnous.com
import * as LobeIcons from '@lobehub/icons'
import type React from 'react'

import { IconGondola } from '@/assets/custom/icon-gondola'
import { IconSub2api } from '@/assets/custom/icon-sub2api'

const CUSTOM_ICONS: Record<string, React.ComponentType<{ size?: number }>> = {
Sub2API: IconSub2api,
Gondola: IconGondola,
}

/**
Expand Down
Loading