Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
const [proxyUrl, setProxyUrl] = useState(() => initialRow?.settings?.proxy?.url || '');
const [proxyUsername, setProxyUsername] = useState(() => initialRow?.settings?.proxy?.username || '');
const [proxyPassword, setProxyPassword] = useState(() => initialRow?.settings?.proxy?.password || '');
const [insecureSkipVerify, setInsecureSkipVerify] = useState(() => initialRow?.settings?.insecureSkipVerify ?? false);
const [passThroughUserAgent, setPassThroughUserAgent] = useState<boolean | null>(() => {
return initialRow?.settings?.passThroughUserAgent ?? null;
});
Expand Down Expand Up @@ -1216,6 +1217,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe

if (isEdit && currentRow) {
const nextSettings = mergeChannelSettingsForUpdate(settingsForSubmit, {
insecureSkipVerify,
passThroughUserAgent,
passThroughBody,
retryableStatusCodes,
Expand Down Expand Up @@ -1260,6 +1262,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe

const nextSettings = mergeChannelSettingsForUpdate(settingsForSubmit, {
proxy: proxyConfig,
insecureSkipVerify,
passThroughUserAgent,
passThroughBody,
retryableStatusCodes,
Expand Down Expand Up @@ -1422,6 +1425,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
baseURL,
apiKey: firstApiKey || undefined,
channelID: isEdit ? currentRow?.id : undefined,
insecureSkipVerify,
});

if (result.error) {
Expand All @@ -1444,7 +1448,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
} catch (_error) {
// Error is already handled by the mutation
}
}, [fetchModels, form, isEdit, currentRow]);
}, [fetchModels, form, insecureSkipVerify, isEdit, currentRow]);

const handleSyncNow = useCallback(async () => {
if (!currentRow) return [];
Expand Down Expand Up @@ -1691,6 +1695,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
setProxyUrl(initialRow?.settings?.proxy?.url || '');
setProxyUsername(initialRow?.settings?.proxy?.username || '');
setProxyPassword(initialRow?.settings?.proxy?.password || '');
setInsecureSkipVerify(initialRow?.settings?.insecureSkipVerify ?? false);
setPassThroughUserAgent(initialRow?.settings?.passThroughUserAgent ?? null);
setPassThroughBody(initialRow?.settings?.passThroughBody ?? null);
setRetryableStatusCodesText(formatRetryableStatusCodes(initialRow?.settings?.retryableStatusCodes));
Expand Down Expand Up @@ -2173,6 +2178,24 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
)}
/>

<FormItem className='grid grid-cols-1 items-start gap-x-6 gap-y-2 md:grid-cols-8'>
<FormLabel htmlFor='channel-insecure-skip-verify' className='pt-2 font-medium md:col-span-2 md:text-right'>
{t('channels.dialogs.fields.insecureSkipVerify.label')}
</FormLabel>
<div className='md:col-span-6'>
<div className='flex items-start gap-3 rounded-md border p-3'>
<Checkbox
id='channel-insecure-skip-verify'
checked={insecureSkipVerify}
onCheckedChange={(checked) => setInsecureSkipVerify(checked === true)}
/>
<p className='text-xs text-amber-600 dark:text-amber-400'>
{t('channels.dialogs.fields.insecureSkipVerify.warning')}
</p>
</div>
</div>
</FormItem>

{(!(isCodexType || isClaudeCodeType || isCopilotType) || authMode === 'third-party') &&
selectedProvider !== 'antigravity' &&
selectedType !== 'anthropic_gcp' && (
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/features/channels/data/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const CREATE_CHANNEL_MUTATION = `
username
password
}
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -172,6 +173,7 @@ const DUPLICATE_CHANNEL_MUTATION = `
username
password
}
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -245,6 +247,7 @@ const BULK_CREATE_CHANNELS_MUTATION = `
username
password
}
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -318,6 +321,7 @@ const UPDATE_CHANNEL_MUTATION = `
username
password
}
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -508,6 +512,7 @@ const BULK_IMPORT_CHANNELS_MUTATION = `
hideOriginalModels
hideMappedModels
lowercaseModelId
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -706,6 +711,7 @@ const BULK_UPDATE_CHANNEL_ORDERING_MUTATION = `
hideOriginalModels
hideMappedModels
lowercaseModelId
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -856,6 +862,7 @@ const QUERY_CHANNELS_QUERY = `
username
password
}
insecureSkipVerify
transformOptions {
forceArrayInstructions
forceArrayInputs
Expand Down Expand Up @@ -1617,7 +1624,13 @@ export function useFetchModels() {
const { handleError } = useErrorHandler();

return useMutation({
mutationFn: async (input: { channelType: string; baseURL: string; apiKey?: string; channelID?: string }) => {
mutationFn: async (input: {
channelType: string;
baseURL: string;
apiKey?: string;
channelID?: string;
insecureSkipVerify?: boolean;
}) => {
try {
const data = await graphqlRequest<{
fetchModels: {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/channels/data/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export const channelSettingsSchema = z.object({
bodyOverrideOperations: z.array(overrideOperationSchema).optional(),
headerOverrideOperations: z.array(overrideOperationSchema).optional(),
proxy: proxyConfigSchema.optional().nullable(),
insecureSkipVerify: z.boolean().optional(),
transformOptions: transformOptionsSchema.optional(),
passThroughUserAgent: z.boolean().optional().nullable(),
passThroughBody: z.boolean().optional().nullable(),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/channels/utils/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function mergeChannelSettingsForUpdate(
bodyOverrideOperations: pick('bodyOverrideOperations', existing?.bodyOverrideOperations ?? []),
headerOverrideOperations: pick('headerOverrideOperations', existing?.headerOverrideOperations ?? []),
proxy: pick('proxy', existing?.proxy ?? null),
insecureSkipVerify: pick('insecureSkipVerify', existing?.insecureSkipVerify ?? false),
transformOptions: pick('transformOptions', existing?.transformOptions ?? undefined),
passThroughUserAgent: pick('passThroughUserAgent', existing?.passThroughUserAgent ?? null),
passThroughBody: pick('passThroughBody', existing?.passThroughBody ?? null),
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en/channels.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@
"channels.dialogs.fields.baseURL.placeholder": "https://api.openai.com/v1",
"channels.dialogs.fields.baseURL.errors.httpScheme": "HTTP transport requires an http:// or https:// Base URL",
"channels.dialogs.fields.baseURL.errors.websocketScheme": "WebSocket transport requires a ws:// or wss:// Base URL",
"channels.dialogs.fields.insecureSkipVerify.label": "Ignore HTTPS Certificate",
"channels.dialogs.fields.insecureSkipVerify.warning": "Disables certificate verification for this channel. Use only with trusted self-signed endpoints; this makes man-in-the-middle attacks possible.",
"channels.dialogs.fields.streamPolicy.label": "Stream Policy",
"channels.dialogs.fields.streamPolicy.placeholder": "Select policy",
"channels.dialogs.fields.streamPolicy.options.unlimited": "Unlimited (follow request)",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/zh-CN/channels.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@
"channels.dialogs.fields.baseURL.placeholder": "https://api.openai.com/v1",
"channels.dialogs.fields.baseURL.errors.httpScheme": "HTTP 传输要求 Base URL 使用 http:// 或 https://",
"channels.dialogs.fields.baseURL.errors.websocketScheme": "WebSocket 传输要求 Base URL 使用 ws:// 或 wss://",
"channels.dialogs.fields.insecureSkipVerify.label": "忽略 HTTPS 证书",
"channels.dialogs.fields.insecureSkipVerify.warning": "仅用于可信的自签名 HTTPS 端点。启用后此渠道不再校验证书,可能遭受中间人攻击。",
"channels.dialogs.fields.streamPolicy.label": "流式策略",
"channels.dialogs.fields.streamPolicy.placeholder": "选择策略",
"channels.dialogs.fields.streamPolicy.options.unlimited": "没有限制 (遵循请求)",
Expand Down
4 changes: 4 additions & 0 deletions internal/objects/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ type ChannelSettings struct {
// Proxy configuration for the channel. If not set, defaults to environment proxy type.
Proxy *httpclient.ProxyConfig `json:"proxy,omitempty"`

// InsecureSkipVerify disables TLS certificate verification for this channel.
// Keep this disabled unless the upstream uses a trusted self-signed certificate.
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`

// TransformOptions configures the transform options for the channel.
TransformOptions TransformOptions `json:"transformOptions"`

Expand Down
17 changes: 12 additions & 5 deletions internal/server/biz/channel_llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,21 @@ func getProxyConfig(channelSettings *objects.ChannelSettings) *httpclient.ProxyC
return channelSettings.Proxy
}

// getHttpClient returns the injected default HTTP client when no custom proxy is configured,
// or creates a new one with proxy support (inheriting TLS settings from the default client).
// getHttpClient returns an HTTP client configured for this channel.
func (svc *ChannelService) getHttpClient(channelSettings *objects.ChannelSettings) *httpclient.HttpClient {
if channelSettings == nil || channelSettings.Proxy == nil {
return svc.httpClient
httpClient := svc.httpClient
if channelSettings == nil {
return httpClient
}

if channelSettings.Proxy != nil {
httpClient = httpClient.WithProxy(channelSettings.Proxy)
}
if channelSettings.InsecureSkipVerify {
httpClient = httpClient.WithInsecureSkipVerify(true)
}

return svc.httpClient.WithProxy(channelSettings.Proxy)
return httpClient
}

// buildChannel creates a Channel with precomputed caches (transformer is set separately).
Expand Down
34 changes: 34 additions & 0 deletions internal/server/biz/channel_llm_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
package biz

import (
"net/http"
"testing"

"github.com/stretchr/testify/require"

"github.com/looplj/axonhub/internal/ent"
"github.com/looplj/axonhub/internal/objects"
"github.com/looplj/axonhub/llm/httpclient"
)

func TestChannelServiceGetHTTPClient_PerChannelTLSIsolation(t *testing.T) {
defaultClient := httpclient.NewHttpClient()
svc := &ChannelService{httpClient: defaultClient}

secureClient := svc.getHttpClient(&objects.ChannelSettings{})
require.Same(t, defaultClient, secureClient)
require.Nil(t, secureClient.GetNativeClient().Transport)

insecureClient := svc.getHttpClient(&objects.ChannelSettings{InsecureSkipVerify: true})
transport, ok := insecureClient.GetNativeClient().Transport.(*http.Transport)
require.True(t, ok)
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)
require.Nil(t, defaultClient.GetNativeClient().Transport)
}

func TestChannelServiceGetHTTPClient_PreservesProxyAndGlobalTLS(t *testing.T) {
defaultClient := httpclient.NewHttpClient(httpclient.WithInsecureSkipVerify(true))
svc := &ChannelService{httpClient: defaultClient}
settings := &objects.ChannelSettings{
Proxy: &httpclient.ProxyConfig{Type: httpclient.ProxyTypeDisabled},
}

channelClient := svc.getHttpClient(settings)
transport, ok := channelClient.GetNativeClient().Transport.(*http.Transport)
require.True(t, ok)
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)

proxyURL, err := transport.Proxy(&http.Request{})
require.NoError(t, err)
require.Nil(t, proxyURL)
}

func TestChannel_IsModelSupported_WithExtraModelPrefix(t *testing.T) {
tests := []struct {
name string
Expand Down
16 changes: 12 additions & 4 deletions internal/server/biz/model_fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ type FetchModelsInput struct {
ChannelType string
BaseURL string
//nolint:gosec // G117: Field name contains "APIKey" but this is input data, not a hardcoded secret
APIKey *string
ChannelID *int
APIKey *string
ChannelID *int
InsecureSkipVerify *bool
}

// FetchModelsResult represents the result of fetching models.
Expand Down Expand Up @@ -246,8 +247,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)
}

var (
apiKey string
proxyConfig *httpclient.ProxyConfig
apiKey string
proxyConfig *httpclient.ProxyConfig
insecureSkipVerify = lo.FromPtrOr(input.InsecureSkipVerify, false)
)

if input.APIKey != nil && *input.APIKey != "" {
Expand Down Expand Up @@ -287,6 +289,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)

if ch.Settings != nil {
proxyConfig = ch.Settings.Proxy
if input.InsecureSkipVerify == nil {
insecureSkipVerify = ch.Settings.InsecureSkipVerify
}
}
}

Expand Down Expand Up @@ -359,6 +364,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput)
if proxyConfig != nil {
httpClient = f.httpClient.WithProxy(proxyConfig)
}
if insecureSkipVerify {
httpClient = httpClient.WithInsecureSkipVerify(true)
}

if channelType.IsGemini() {
models, err := f.fetchGeminiModels(ctx, httpClient, req)
Expand Down
32 changes: 32 additions & 0 deletions internal/server/biz/model_fetcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/looplj/axonhub/internal/authz"
"github.com/looplj/axonhub/internal/ent/channel"
"github.com/looplj/axonhub/internal/ent/enttest"
Expand Down Expand Up @@ -511,6 +513,36 @@ func TestFetchModelsGeminiPagination(t *testing.T) {
}
}

func TestFetchModelsInsecureSkipVerify(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"self-signed-model"}]}`))
}))
defer server.Close()

fetcher := NewModelFetcher(httpclient.NewHttpClient(), nil)
apiKey := "test-key"

secureResult, err := fetcher.FetchModels(t.Context(), FetchModelsInput{
ChannelType: channel.TypeOpenai.String(),
BaseURL: server.URL,
APIKey: &apiKey,
})
require.NoError(t, err)
require.NotNil(t, secureResult.Error)

insecure := true
insecureResult, err := fetcher.FetchModels(t.Context(), FetchModelsInput{
ChannelType: channel.TypeOpenai.String(),
BaseURL: server.URL,
APIKey: &apiKey,
InsecureSkipVerify: &insecure,
})
require.NoError(t, err)
require.Nil(t, insecureResult.Error)
require.Equal(t, []ModelIdentify{{ID: "self-signed-model"}}, insecureResult.Models)
}

func TestFetchModelsWithChannelIDUsesStoredCredentialsOnlyForStoredEndpoint(t *testing.T) {
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
3 changes: 3 additions & 0 deletions internal/server/gql/axonhub.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ type ChannelSettings {
hideMappedModels: Boolean
lowercaseModelId: Boolean
proxy: ProxyConfig
insecureSkipVerify: Boolean
transformOptions: TransformOptions
headerOverrideOperations: [OverrideOperation!]! @goField(forceResolver: true)
bodyOverrideOperations: [OverrideOperation!]! @goField(forceResolver: true)
Expand Down Expand Up @@ -194,6 +195,7 @@ input ChannelSettingsInput {
hideMappedModels: Boolean
lowercaseModelId: Boolean
proxy: ProxyConfigInput
insecureSkipVerify: Boolean
transformOptions: TransformOptionsInput
headerOverrideOperations: [OverrideOperationInput!]
bodyOverrideOperations: [OverrideOperationInput!]
Expand Down Expand Up @@ -522,6 +524,7 @@ input FetchModelsInput {
baseURL: String!
apiKey: String
channelID: ID
insecureSkipVerify: Boolean
}

type FetchModelsPayload {
Expand Down
Loading
Loading