Repository navigation
feat: add Gondola channel #6672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } |
| 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" |
| 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} | ||
| > | ||
| <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> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
|
@@ -81,12 +83,13 @@ export const CHANNEL_TYPES = { | |
| 58: 'Advanced Custom', | ||
| 59: 'Sub2API', | ||
| 60: 'New API', | ||
| 61: 'Gondola', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
As per coding guidelines, “面向用户的文案必须使用 i18n” and “常量中的消息和状态 label 应保存翻译键,并在展示时通过 📍 Affects 3 files
🤖 Prompt for AI AgentsSource: 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 }[] = (() => { | ||
|
|
@@ -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> = { | ||
|
|
@@ -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> = { | ||
|
|
||
| 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 | ||
| ) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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.
IconGondolahas no accessible name and noaria-hiddenattribute. Mark itaria-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 orcurrentColorwith theme-controlled styling.As per coding guidelines, “装饰性图标使用
aria-hidden="true"” and “主题使用 CSS 变量与dark:”.🤖 Prompt for AI Agents
Source: Coding guidelines