diff --git a/config.example.yaml b/config.example.yaml
index 9e53e2e0..71ce90bc 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -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(其默认开启思考)
diff --git a/internal/config/config.go b/internal/config/config.go
index 776bd4e4..f29d41d2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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,
}
}
diff --git a/internal/config/envexpand.go b/internal/config/envexpand.go
index 26faa0c1..4cd2154c 100644
--- a/internal/config/envexpand.go
+++ b/internal/config/envexpand.go
@@ -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
+}
diff --git a/internal/handler/config.go b/internal/handler/config.go
index f963f47a..d140353d 100644
--- a/internal/handler/config.go
+++ b/internal/handler/config.go
@@ -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
diff --git a/internal/handler/task_manager.go b/internal/handler/task_manager.go
index e1b4301b..d9398730 100644
--- a/internal/handler/task_manager.go
+++ b/internal/handler/task_manager.go
@@ -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 {
diff --git a/internal/openai/eino_http.go b/internal/openai/eino_http.go
index 625da8ac..7b63bdbd 100644
--- a/internal/openai/eino_http.go
+++ b/internal/openai/eino_http.go
@@ -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
}
diff --git a/internal/openai/openai.go b/internal/openai/openai.go
index 318813e4..b6ad250e 100644
--- a/internal/openai/openai.go
+++ b/internal/openai/openai.go
@@ -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 {
diff --git a/internal/openai/request_headers.go b/internal/openai/request_headers.go
new file mode 100644
index 00000000..4f21129f
--- /dev/null
+++ b/internal/openai/request_headers.go
@@ -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)
+}
diff --git a/web/static/js/settings.js b/web/static/js/settings.js
index fca5c069..4fa7b2ab 100644
--- a/web/static/js/settings.js
+++ b/web/static/js/settings.js
@@ -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 || {}
})
});
diff --git a/web/templates/index.html b/web/templates/index.html
index 65c782bf..93f2c122 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -3811,6 +3811,19 @@
允许对话页覆盖推理选项
+
+
+
+ 自定义请求头
+ 每行一条 KEY=VALUE;值支持 ${ENV_VAR}、${ENV_VAR:-默认值} 与 %session_id%(自动填当前对话 ID);同名头会覆盖内置 Authorization。
+
+
+
+
+
+ 用于要求私有鉴权头、版本头或组织头的第三方 OpenAI 兼容供应商(例如 opencode 等聚合网关)。留空表示不附加。
+
+