mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-13 15:57:22 +01:00
feat(headers): 支持为 AI 渠道配置自定义 HTTP 请求头
部分 OpenAI 兼容供应商(如 opencode)要求私有鉴权头或会话头,
而非标准的 Authorization: Bearer,导致无法接入。
- config: OpenAIConfig / AIChannelConfig 新增 headers 字段,
值支持 ${ENV_VAR}、${ENV_VAR:-default} 与 %session_id% 占位符
- openai: 新增 headersRoundTripper 接入 Eino ChatModel 传输链,
同名的内置头(Authorization/User-Agent 等)可被覆盖
- openai: 内置 Client 的 4 处出站请求同样注入自定义头
- handler: 任务开始时把对话 ID 注入 ctx,供 %session_id% 替换;
连接测试没有真实会话,使用探测值 connect-test
- web: 渠道设置页新增“自定义请求头”编辑区(每行 KEY=VALUE)
- 占位符使用 %session_id% 而非 {{session_id}},因为 index.html
由 Go html/template 渲染,双花括号会被当作模板函数解析
This commit is contained in:
1 parent
bc0b288d40
commit
d6b0aed1de
10 files changed
+255
-18
No files matched your search
@@ -108,6 +108,14 @@ ai:
|
||||
model: qwen3-max
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 32768
|
||||
# headers: 可选。追加或覆盖发给模型网关的 HTTP 请求头,值支持 ${ENV_VAR} 与 ${ENV_VAR:-默认值}。
|
||||
# 用于兼容要求私有鉴权头、版本头或组织头的第三方 OpenAI 兼容供应商(如 opencode 等聚合网关)。
|
||||
# 配置后会覆盖同名的内置头(Authorization、User-Agent 等),空值项会被忽略。
|
||||
# headers:
|
||||
# X-Api-Key: ${OPENCODE_API_KEY}
|
||||
# x-opencode-session: "%session_id%" # 自动替换为当前对话 ID
|
||||
# X-Client-Name: cyberstrike-ai
|
||||
# HTTP-Referer: https://example.com
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
|
||||
@@ -847,6 +847,9 @@ type OpenAIConfig struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
|
||||
// Headers 追加或覆盖发给模型网关的 HTTP 请求头,值支持 ${ENV_VAR} 展开。
|
||||
// 用于兼容要求私有鉴权头、版本头或组织头的第三方 OpenAI 兼容供应商。
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(Eino 单/多代理路径生效)。
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
@@ -866,6 +869,7 @@ type AIChannelConfig struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
@@ -881,6 +885,7 @@ func (c AIChannelConfig) ToOpenAIConfig() OpenAIConfig {
|
||||
Model: c.Model,
|
||||
MaxTotalTokens: c.MaxTotalTokens,
|
||||
MaxCompletionTokens: c.MaxCompletionTokens,
|
||||
Headers: ExpandHeaderMap(c.Headers),
|
||||
Reasoning: c.Reasoning,
|
||||
}
|
||||
}
|
||||
@@ -897,6 +902,7 @@ func AIChannelFromOpenAI(id, name string, oa OpenAIConfig) AIChannelConfig {
|
||||
Model: oa.Model,
|
||||
MaxTotalTokens: oa.MaxTotalTokens,
|
||||
MaxCompletionTokens: oa.MaxCompletionTokens,
|
||||
Headers: ExpandHeaderMap(oa.Headers),
|
||||
Reasoning: oa.Reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +71,16 @@ func ExpandConfigEnv(cfg *ExternalMCPServerConfig) {
|
||||
cfg.Headers[k] = expandEnvVar(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandHeaderMap 返回 headers 的副本,并展开其中的 ${VAR} / ${VAR:-default} 引用。
|
||||
// 与 MCP 配置的 Headers 展开规则保持一致。
|
||||
func ExpandHeaderMap(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(headers))
|
||||
for k, v := range headers {
|
||||
out[expandEnvVar(k)] = expandEnvVar(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+48
-13
@@ -351,13 +351,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
subAgentCount = len(agents.MergeYAMLAndMarkdown(h.config.MultiAgent.SubAgents, load.SubAgents))
|
||||
}
|
||||
multiPub := config.MultiAgentPublic{
|
||||
Enabled: h.config.MultiAgent.Enabled,
|
||||
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
|
||||
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
||||
SubAgentCount: subAgentCount,
|
||||
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
|
||||
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
|
||||
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
|
||||
Enabled: h.config.MultiAgent.Enabled,
|
||||
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
|
||||
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
||||
SubAgentCount: subAgentCount,
|
||||
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
|
||||
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
|
||||
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
|
||||
SummarizationUserIntentLedgerEntryMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerEntryMaxRunesEffective(),
|
||||
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
|
||||
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
|
||||
@@ -1262,12 +1262,13 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// TestOpenAIRequest 测试OpenAI连接请求
|
||||
type TestOpenAIRequest struct {
|
||||
CredentialScope string `json:"credential_scope,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
CredentialScope string `json:"credential_scope,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// TestOpenAI 测试OpenAI API连接是否可用
|
||||
@@ -1317,11 +1318,15 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(req.APIKey),
|
||||
Model: req.Model,
|
||||
Headers: config.ExpandHeaderMap(req.Headers),
|
||||
}
|
||||
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
// 连接测试没有真实对话上下文,用一个明确的探测值填充 %session_id%,
|
||||
// 这样带会话占位符的渠道也能在测试时把自定义头真实发出去。
|
||||
ctx = openai.WithSessionID(ctx, "connect-test")
|
||||
|
||||
start := time.Now()
|
||||
if llm.IsClaudeProvider(req.Provider) {
|
||||
@@ -2150,6 +2155,7 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
setStringInMap(openaiNode, "api_key", cfg.APIKey)
|
||||
setStringInMap(openaiNode, "base_url", cfg.BaseURL)
|
||||
setStringInMap(openaiNode, "model", cfg.Model)
|
||||
setStringMapInMap(openaiNode, "headers", cfg.Headers)
|
||||
if cfg.MaxTotalTokens > 0 {
|
||||
setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens)
|
||||
}
|
||||
@@ -2206,6 +2212,7 @@ func updateAIConfig(doc *yaml.Node, cfg config.AIConfig) {
|
||||
setStringInMap(channelNode, "api_key", ch.APIKey)
|
||||
setStringInMap(channelNode, "base_url", ch.BaseURL)
|
||||
setStringInMap(channelNode, "model", ch.Model)
|
||||
setStringMapInMap(channelNode, "headers", ch.Headers)
|
||||
if ch.MaxTotalTokens > 0 {
|
||||
setIntInMap(channelNode, "max_total_tokens", ch.MaxTotalTokens)
|
||||
}
|
||||
@@ -2607,6 +2614,34 @@ func setStringInMap(mapNode *yaml.Node, key, value string) {
|
||||
valueNode.Value = value
|
||||
}
|
||||
|
||||
// setStringMapInMap 把 map[string]string 写成 YAML 映射节点(键排序,空值删除)。
|
||||
func setStringMapInMap(mapNode *yaml.Node, key string, values map[string]string) {
|
||||
if len(values) == 0 {
|
||||
removeKeyFromMap(mapNode, key)
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
removeKeyFromMap(mapNode, key)
|
||||
return
|
||||
}
|
||||
sort.Strings(keys)
|
||||
mapValue := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
for _, k := range keys {
|
||||
mapValue.Content = append(mapValue.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k},
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: values[k]})
|
||||
}
|
||||
_, valueNode := ensureKeyValue(mapNode, key)
|
||||
*valueNode = *mapValue
|
||||
}
|
||||
|
||||
func removeKeyFromMap(mapNode *yaml.Node, key string) {
|
||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
||||
return
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/runlease"
|
||||
"cyberstrike-ai/internal/security"
|
||||
)
|
||||
@@ -584,6 +585,9 @@ func (m *AgentTaskManager) UpdateTaskStatus(conversationID string, status string
|
||||
// BindProcessScope snapshots ownership once at task start. Continuations must
|
||||
// derive from this context, never resolve ownership again using conversation ID.
|
||||
func (m *AgentTaskManager) BindProcessScope(ctx context.Context, conversationID, runID string) context.Context {
|
||||
// 把对话 ID 放进 ctx:自定义请求头的 %session_id% 占位符据此替换,
|
||||
// 保证每次对话发出的 x-opencode-session 等头对应当前 session。
|
||||
ctx = openai.WithSessionID(ctx, conversationID)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if task := m.tasks[conversationID]; task != nil && task.RunID == runID {
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
||||
transport = &einoSSEErrorRoundTripper{base: transport}
|
||||
transport = &einoSSESanitizingRoundTripper{base: transport}
|
||||
transport = &einoUsageNormalizingRoundTripper{base: transport}
|
||||
transport = &headersRoundTripper{base: transport, cfg: cfg}
|
||||
cloned.Transport = transport
|
||||
return &cloned
|
||||
}
|
||||
@@ -115,6 +115,7 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
applyCustomHeaders(req, c.config)
|
||||
|
||||
requestStart := time.Now()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -207,6 +208,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
applyCustomHeaders(req, c.config)
|
||||
|
||||
requestStart := time.Now()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -357,6 +359,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
applyCustomHeaders(req, c.config)
|
||||
|
||||
requestStart := time.Now()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -572,6 +575,7 @@ func (c *Client) ListModels(ctx context.Context) ([]string, error) {
|
||||
return nil, fmt.Errorf("build openai models request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
applyCustomHeaders(req, c.config)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
// sessionHeaderPlaceholder 是自定义请求头值里代表「当前对话 ID」的占位符。
|
||||
// 写入方式(配置文件或设置页)示例:
|
||||
//
|
||||
// headers:
|
||||
// x-opencode-session: %session_id%
|
||||
//
|
||||
// 实际下发时会替换为当前对话的 conversation id。
|
||||
// 注意:不要使用 {{...}} 语法,index.html 是 Go 模板,会被当作模板函数解析。
|
||||
const sessionHeaderPlaceholder = "%session_id%"
|
||||
|
||||
type sessionIDContextKey struct{}
|
||||
|
||||
// WithSessionID 把当前对话 ID 注入 ctx。handler 在任务开始时注入一次,
|
||||
// 后续 Eino ChatModel 与内置 Client 发出的 HTTP 请求都能在请求上下文里读到。
|
||||
func WithSessionID(ctx context.Context, sessionID string) context.Context {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
id := strings.TrimSpace(sessionID)
|
||||
if id == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, sessionIDContextKey{}, id)
|
||||
}
|
||||
|
||||
// SessionIDFromContext 读取 ctx 中的对话 ID,未注入时返回空串。
|
||||
func SessionIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := ctx.Value(sessionIDContextKey{}).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// renderHeaderValue 把值里的 %session_id% 替换为当前对话 ID。
|
||||
// 第二个返回值为 false 表示该头依赖会话 ID 但上下文里取不到,应当跳过下发,
|
||||
// 避免把字面量 "%session_id%" 发给上游造成脏数据。
|
||||
func renderHeaderValue(value, sessionID string) (string, bool) {
|
||||
if !strings.Contains(value, sessionHeaderPlaceholder) {
|
||||
return value, true
|
||||
}
|
||||
id := strings.TrimSpace(sessionID)
|
||||
if id == "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.ReplaceAll(value, sessionHeaderPlaceholder, id), true
|
||||
}
|
||||
|
||||
// normalizedCustomHeaders 返回规范化后的自定义请求头:
|
||||
// 去掉名称两端空白,丢弃空名称/空值,便于安全地写入 http.Header。
|
||||
func normalizedCustomHeaders(cfg *config.OpenAIConfig) map[string]string {
|
||||
if cfg == nil || len(cfg.Headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(cfg.Headers))
|
||||
for name, value := range cfg.Headers {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyCustomHeaders 把渠道配置的自定义请求头写入请求。
|
||||
// 同名的内置头(Authorization、Content-Type 等)会被配置值覆盖,
|
||||
// 因此只接受私有鉴权头的第三方 OpenAI 兼容网关也能正常调用。
|
||||
// 值中的 %session_id% 会替换为请求上下文里的当前对话 ID。
|
||||
func applyCustomHeaders(req *http.Request, cfg *config.OpenAIConfig) {
|
||||
if req == nil || req.Header == nil {
|
||||
return
|
||||
}
|
||||
headers := normalizedCustomHeaders(cfg)
|
||||
if len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
sessionID := SessionIDFromContext(req.Context())
|
||||
for name, value := range headers {
|
||||
rendered, ok := renderHeaderValue(value, sessionID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(name, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
// headersRoundTripper 为经过 Eino ChatModel 的请求注入渠道自定义请求头。
|
||||
type headersRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
cfg *config.OpenAIConfig
|
||||
}
|
||||
|
||||
func (rt *headersRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := http.DefaultTransport
|
||||
if rt != nil && rt.base != nil {
|
||||
base = rt.base
|
||||
}
|
||||
if rt == nil || req == nil {
|
||||
return base.RoundTrip(req)
|
||||
}
|
||||
applyCustomHeaders(req, rt.cfg)
|
||||
return base.RoundTrip(req)
|
||||
}
|
||||
@@ -2666,6 +2666,31 @@ function ensureAIConfigShape(cfg) {
|
||||
return normalizeAIConfigProviderProfiles({ default_channel: def, channels });
|
||||
}
|
||||
|
||||
// parseCustomHeadersInput 把「每行一条 KEY=VALUE」的文本解析为请求头对象。
|
||||
// 空行与 # 开头的行会被忽略;键或值为空的行直接跳过。
|
||||
function parseCustomHeadersInput() {
|
||||
const el = document.getElementById('openai-custom-headers');
|
||||
if (!el) return {};
|
||||
const out = {};
|
||||
String(el.value || '').split(/\r?\n/).forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.charAt(0) === '#') return;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx <= 0) return;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key || !value) return;
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// formatCustomHeaders 把请求头对象写回「每行一条 KEY=VALUE」的文本。
|
||||
function formatCustomHeaders(headers) {
|
||||
if (!headers || typeof headers !== 'object') return '';
|
||||
return Object.keys(headers).sort().map((k) => k + '=' + headers[k]).join('\n');
|
||||
}
|
||||
|
||||
function readAIChannelFromMainForm(id) {
|
||||
const prev = currentConfig?.ai?.channels?.[id] || {};
|
||||
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 32768;
|
||||
@@ -2678,6 +2703,7 @@ function readAIChannelFromMainForm(id) {
|
||||
model: document.getElementById('openai-model')?.value.trim() || '',
|
||||
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 120000,
|
||||
max_completion_tokens: maxCompletionTokens,
|
||||
headers: parseCustomHeadersInput(),
|
||||
reasoning: {
|
||||
...(prev.reasoning || {}),
|
||||
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
||||
@@ -2710,6 +2736,8 @@ function writeAIChannelToMainForm(id) {
|
||||
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
||||
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
||||
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 32768;
|
||||
const customHeadersEl = document.getElementById('openai-custom-headers');
|
||||
if (customHeadersEl) customHeadersEl.value = formatCustomHeaders(ch.headers);
|
||||
const r = ch.reasoning || {};
|
||||
const modeEl = document.getElementById('openai-reasoning-mode');
|
||||
if (modeEl) {
|
||||
@@ -2814,7 +2842,8 @@ function bindAIChannelEditorPreviewSync() {
|
||||
'openai-provider',
|
||||
'openai-api-key',
|
||||
'openai-base-url',
|
||||
'openai-model'
|
||||
'openai-model',
|
||||
'openai-custom-headers'
|
||||
];
|
||||
ids.forEach((fieldId) => {
|
||||
const el = document.getElementById(fieldId);
|
||||
@@ -3256,7 +3285,8 @@ async function probeSelectedAIChannels() {
|
||||
channel_id: id,
|
||||
base_url: ch.base_url || '',
|
||||
api_key: ch.api_key || '',
|
||||
model: ch.model || ''
|
||||
model: ch.model || '',
|
||||
headers: ch.headers || {}
|
||||
})
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
@@ -3769,11 +3799,16 @@ function collectHitlAuditModelEffectiveConfig() {
|
||||
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
|
||||
model: document.getElementById('openai-model')?.value.trim() || ''
|
||||
};
|
||||
const overriddenBaseUrl = document.getElementById('hitl-audit-model-base-url')?.value.trim() || '';
|
||||
const mainChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
|
||||
const mainHeaders = currentConfig?.ai?.channels?.[mainChannelId]?.headers || {};
|
||||
return {
|
||||
provider: document.getElementById('hitl-audit-model-provider')?.value || main.provider,
|
||||
base_url: document.getElementById('hitl-audit-model-base-url')?.value.trim() || main.base_url,
|
||||
base_url: overriddenBaseUrl || main.base_url,
|
||||
api_key: document.getElementById('hitl-audit-model-api-key')?.value.trim() || main.api_key,
|
||||
model: document.getElementById('hitl-audit-model-name')?.value.trim() || main.model
|
||||
model: document.getElementById('hitl-audit-model-name')?.value.trim() || main.model,
|
||||
// 审计模型留空即复用主渠道,自定义请求头必须一起跟随,否则私有鉴权头的供应商测试必然失败。
|
||||
headers: overriddenBaseUrl ? {} : mainHeaders
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3952,7 +3987,8 @@ async function testOpenAIConnection() {
|
||||
channel_id: channelId,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: model
|
||||
model: model,
|
||||
headers: currentConfig?.ai?.channels?.[channelId]?.headers || {}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -3811,6 +3811,19 @@
|
||||
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
||||
</label>
|
||||
</details>
|
||||
<details class="ai-channel-form-section ai-channel-advanced-section">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>自定义请求头</strong>
|
||||
<small>每行一条 KEY=VALUE;值支持 ${ENV_VAR}、${ENV_VAR:-默认值} 与 %session_id%(自动填当前对话 ID);同名头会覆盖内置 Authorization。</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="form-group">
|
||||
<label for="openai-custom-headers">请求头</label>
|
||||
<textarea id="openai-custom-headers" rows="4" spellcheck="false" placeholder="X-Api-Key=sk-xxx x-opencode-session=%session_id% HTTP-Referer=https://example.com"></textarea>
|
||||
<small class="form-hint">用于要求私有鉴权头、版本头或组织头的第三方 OpenAI 兼容供应商(例如 opencode 等聚合网关)。留空表示不附加。</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user