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 等聚合网关)。留空表示不附加。 +
+