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:
root committed 2026-10-30 20:13:56 +08:00
1 parent bc0b288d40
commit d6b0aed1de
10 files changed
+255 -18

No files matched your search

+8
View File
@@ -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(其默认开启思考)
+6
View File
@@ -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,
}
}
+13
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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 {
+1
View File
@@ -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
}
+4
View File
@@ -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 {
+117
View File
@@ -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)
}
+41 -5
View File
@@ -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 || {}
})
});
+13
View File
@@ -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&#10;x-opencode-session=%session_id%&#10;HTTP-Referer=https://example.com"></textarea>
<small class="form-hint">用于要求私有鉴权头、版本头或组织头的第三方 OpenAI 兼容供应商(例如 opencode 等聚合网关)。留空表示不附加。</small>
</div>
</details>
</div>
</div>
</div>