Merge pull request #379 from nightslient/feat/ai-channel-custom-headers

feat(headers): 支持为 AI 渠道配置自定义 HTTP 请求头
This commit is contained in:
公明 authored and GitHub committed 2026-11-02 17:37:17 +08:00
commit d0951fd3fc
22 files changed
+1154 -16

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
### Added
- AI 渠道支持自定义 HTTP 请求头、环境变量和会话 ID 占位符;保存后的鉴权头可安全用于连接测试,独立审计身份隔离主渠道请求头,自定义头不会跟随跨源重定向。
- 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。
- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。
+12
View File
@@ -108,6 +108,18 @@ 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
# 配置自定义头时,仅允许同源重定向,跨主机、端口或协议的重定向会被拒绝。
# HITL 审计仅在未显式配置 provider/base_url/api_key 时继承主渠道 headers;
# 独立审计身份如需请求头,请在 hitl.audit_model.headers 中单独配置。
# 空 headers 与未配置等价;所有自定义头值在设置接口中均按敏感值脱敏。
# 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(其默认开启思考)
+7
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,
}
}
@@ -1231,6 +1237,7 @@ func (h HitlConfig) TypeSafeConfigEffective() (baseURL, apiKey, model string) {
func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig {
out := main
am := h.AuditModel
out.Headers = auditModelHeaders(main, am)
if strings.TrimSpace(am.Provider) != "" || strings.TrimSpace(am.BaseURL) != "" || strings.TrimSpace(am.Model) != "" {
out.Reasoning.Profile = ""
}
+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
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "strings"
// auditModelHeaders treats an explicit audit identity as a separate credential
// boundary, even when its provider or URL happens to equal the main model's.
// A model-name-only override can safely continue using the main identity.
func auditModelHeaders(main, audit OpenAIConfig) map[string]string {
if len(audit.Headers) > 0 {
return ExpandHeaderMap(audit.Headers)
}
if strings.TrimSpace(audit.Provider) != "" || strings.TrimSpace(audit.BaseURL) != "" || strings.TrimSpace(audit.APIKey) != "" {
return nil
}
return ExpandHeaderMap(main.Headers)
}
+34
View File
@@ -0,0 +1,34 @@
package config
import "testing"
func TestAuditModelHeaderIdentityBoundary(t *testing.T) {
t.Setenv("CSAI_AUDIT_TEST_HEADER", "audit-secret")
main := OpenAIConfig{Provider: "openai", BaseURL: "https://main.invalid/v1", APIKey: "main-key", Headers: map[string]string{"Authorization": "Bearer main"}}
for _, tc := range []struct {
name string
audit OpenAIConfig
want string
}{
{"inherit", OpenAIConfig{}, "Bearer main"},
{"model only", OpenAIConfig{Model: "small"}, "Bearer main"},
{"provider", OpenAIConfig{Provider: "openai"}, ""},
{"endpoint", OpenAIConfig{BaseURL: "https://audit.invalid/v1"}, ""},
{"key", OpenAIConfig{APIKey: "audit-key"}, ""},
{"explicit", OpenAIConfig{BaseURL: "https://audit.invalid", Headers: map[string]string{"Authorization": "${CSAI_AUDIT_TEST_HEADER}"}}, "audit-secret"},
{"empty follows inheritance", OpenAIConfig{Headers: map[string]string{}}, "Bearer main"},
} {
t.Run(tc.name, func(t *testing.T) {
got := (HitlConfig{AuditModel: tc.audit}).AuditModelEffective(main)
if got.Headers["Authorization"] != tc.want {
t.Fatalf("headers=%v", got.Headers)
}
if got.Headers != nil {
got.Headers["Authorization"] = "changed"
}
if main.Headers["Authorization"] != "Bearer main" {
t.Fatal("mutated main headers")
}
})
}
}
+47 -6
View File
@@ -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连接是否可用
@@ -1284,6 +1285,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
return
}
req.APIKey = resolvedKey
resolvedHeaders, headerErr := h.resolveProbeHeaders(req)
if headerErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": headerErr.Error()})
return
}
if strings.TrimSpace(req.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
return
@@ -1317,11 +1323,15 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
BaseURL: baseURL,
APIKey: strings.TrimSpace(req.APIKey),
Model: req.Model,
Headers: resolvedHeaders,
}
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 +2160,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 +2217,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)
}
@@ -2359,6 +2371,7 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL)
setStringInMap(auditModelNode, "api_key", cfg.AuditModel.APIKey)
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
setStringMapInMap(auditModelNode, "headers", cfg.AuditModel.Headers)
setStringInMap(ensureMap(auditModelNode, "reasoning"), "profile", cfg.AuditModel.Reasoning.Profile)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
@@ -2607,6 +2620,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
@@ -0,0 +1,222 @@
package handler
import (
"context"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"encoding/json"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSavedHeaderProbeRestoresScopedSecret(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
}))
defer srv.Close()
cfg := &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"gateway": {APIKey: "dummy", BaseURL: srv.URL, Model: "m", Headers: map[string]string{"X-Api-Key": "review-test-secret"}}}}}
masked, err := maskedConfigResponse(configSecretSnapshot(cfg))
if err != nil {
t.Fatal(err)
}
tree := masked.(map[string]interface{})
hdr := tree["ai"].(map[string]interface{})["channels"].(map[string]interface{})["gateway"].(map[string]interface{})["headers"]
payload, _ := json.Marshal(map[string]interface{}{"channel_id": "gateway", "base_url": srv.URL, "api_key": "********", "model": "m", "headers": hdr})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/api/config/test-openai", strings.NewReader(string(payload)))
c.Request.Header.Set("Content-Type", "application/json")
h := &ConfigHandler{config: cfg, logger: zap.NewNop()}
h.TestOpenAI(c)
if got != "review-test-secret" {
t.Fatalf("expected stored header, got %q", got)
}
t.Log("Saved header restored from matching channel")
}
func TestAuditIndependentProviderDoesNotReceiveMainHeaders(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Authorization")
w.Write([]byte(`{}`))
}))
defer srv.Close()
main := config.OpenAIConfig{BaseURL: "https://main-provider.invalid", APIKey: "main-key", Model: "m", Headers: map[string]string{"Authorization": "Bearer main-header-secret"}}
audit := (config.HitlConfig{AuditModel: config.OpenAIConfig{BaseURL: srv.URL, APIKey: "audit-key", Model: "audit"}}).AuditModelEffective(main)
var out map[string]interface{}
err := openai.NewClient(&audit, nil, zap.NewNop()).ChatCompletion(context.Background(), map[string]string{"model": "audit"}, &out)
if err != nil {
t.Fatal(err)
}
if got != "Bearer audit-key" {
t.Fatalf("unexpected auth %q", got)
}
t.Log("Independent audit identity retained")
}
func TestProbeHeaderScopeAndTargetFailures(t *testing.T) {
stored := config.AIChannelConfig{Provider: "openai", BaseURL: "https://model.invalid/v1", APIKey: "key", Headers: map[string]string{"X-Api-Key": "stored-secret"}}
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"a": stored}}, OpenAI: stored.ToOpenAIConfig()}, logger: zap.NewNop()}
base := TestOpenAIRequest{ChannelID: "a", Provider: "openai", BaseURL: stored.BaseURL, APIKey: "key", Headers: map[string]string{"x-api-key": maskedSecret, "X-Label": "label"}}
got, err := h.resolveProbeHeaders(base)
if err != nil || got["x-api-key"] != "stored-secret" || got["X-Label"] != "label" {
t.Fatalf("restore: %v %v", got, err)
}
for _, tc := range []struct {
name string
edit func(*TestOpenAIRequest)
}{
{"unknown channel", func(r *TestOpenAIRequest) { r.ChannelID = "other" }},
{"unscoped", func(r *TestOpenAIRequest) { r.ChannelID = "" }},
{"different destination", func(r *TestOpenAIRequest) { r.BaseURL = "https://other.invalid/v1" }},
{"different path", func(r *TestOpenAIRequest) { r.BaseURL = "https://model.invalid/another" }},
{"different provider", func(r *TestOpenAIRequest) { r.Provider = "claude" }},
{"different key", func(r *TestOpenAIRequest) { r.APIKey = "another" }},
{"unknown scope", func(r *TestOpenAIRequest) { r.CredentialScope = "other" }},
{"missing saved header", func(r *TestOpenAIRequest) { r.Headers = map[string]string{"Authorization": maskedSecret} }},
{"partial marker", func(r *TestOpenAIRequest) { r.Headers = map[string]string{"X-Api-Key": "Bearer " + maskedSecret} }},
{"duplicate case", func(r *TestOpenAIRequest) {
r.Headers = map[string]string{"X-Api-Key": maskedSecret, "x-api-key": "override"}
}},
} {
t.Run(tc.name, func(t *testing.T) {
r := base
tc.edit(&r)
if _, err := h.resolveProbeHeaders(r); err == nil {
t.Fatal("unsafe marker accepted")
}
})
}
for _, scope := range []string{"openai", "hitlAudit"} {
r := base
r.ChannelID = ""
r.CredentialScope = scope
got, err = h.resolveProbeHeaders(r)
if err != nil || got["x-api-key"] != "stored-secret" {
t.Fatalf("scope %s: %v", scope, err)
}
}
r := base
r.Headers = map[string]string{"X-Api-Key": "fresh"}
r.BaseURL = "https://new.invalid"
got, err = h.resolveProbeHeaders(r)
if err != nil || got["X-Api-Key"] != "fresh" {
t.Fatal("fresh credential rejected")
}
h.config.Hitl.AuditModel = config.OpenAIConfig{BaseURL: "https://audit.invalid", APIKey: "audit-key"}
r = base
r.CredentialScope = "hitlAudit"
r.BaseURL = "https://audit.invalid"
r.APIKey = "audit-key"
if _, err = h.resolveProbeHeaders(r); err == nil {
t.Fatal("main credential restored for independent audit")
}
}
func TestProbeHeaderFailureMakesNoRequest(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ }))
defer srv.Close()
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"a": {APIKey: "key", BaseURL: "https://saved.invalid", Headers: map[string]string{"Authorization": "Bearer secret"}}}}}, logger: zap.NewNop()}
payload, _ := json.Marshal(TestOpenAIRequest{ChannelID: "a", APIKey: "key", BaseURL: srv.URL, Model: "m", Headers: map[string]string{"Authorization": maskedSecret}})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/test", strings.NewReader(string(payload)))
c.Request.Header.Set("Content-Type", "application/json")
h.TestOpenAI(c)
if w.Code != 400 || calls != 0 {
t.Fatalf("status=%d outbound=%d", w.Code, calls)
}
}
func TestAuditHeadersPersistAndRestoreMaskedConfig(t *testing.T) {
cfg := &config.Config{Hitl: config.HitlConfig{AuditModel: config.OpenAIConfig{Headers: map[string]string{"X-Api-Key": "audit-test-secret"}}}}
response, err := maskedConfigResponse(configSecretSnapshot(cfg))
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
var req UpdateConfigRequest
if err = json.Unmarshal(raw, &req); err != nil {
t.Fatal(err)
}
if err = restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
var doc yaml.Node
if err = yaml.Unmarshal([]byte("hitl: {}\n"), &doc); err != nil {
t.Fatal(err)
}
updateHitlConfig(&doc, *req.Hitl)
raw, err = yaml.Marshal(&doc)
if err != nil {
t.Fatal(err)
}
var saved config.Config
if err = yaml.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if saved.Hitl.AuditModel.Headers["X-Api-Key"] != "audit-test-secret" {
t.Fatal("audit header lost on save")
}
}
func TestPrivateHeaderNamesAreMaskedAndRestored(t *testing.T) {
cfg := &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"a": {Headers: map[string]string{"X-Gateway-Token": "private-token", "X-Client-Name": "client-name"}}, "b": {Headers: map[string]string{"X-Gateway-Token": "other-token"}}}}}
response, err := maskedConfigResponse(configSecretSnapshot(cfg))
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(response)
if strings.Contains(string(raw), "private-token") || strings.Contains(string(raw), "other-token") || strings.Contains(string(raw), "client-name") {
t.Fatal("custom header disclosed")
}
var req UpdateConfigRequest
if err = json.Unmarshal(raw, &req); err != nil {
t.Fatal(err)
}
// HTTP field casing is not an identity change, but channels must remain isolated.
ch := req.AI.Channels["a"]
delete(ch.Headers, "X-Gateway-Token")
ch.Headers["x-gateway-token"] = maskedSecret
req.AI.Channels["a"] = ch
if err = restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
if req.AI.Channels["a"].Headers["x-gateway-token"] != "private-token" || req.AI.Channels["b"].Headers["X-Gateway-Token"] != "other-token" {
t.Fatal("wrong channel credential")
}
if cfg.AI.Channels["a"].Headers["X-Gateway-Token"] != "private-token" {
t.Fatal("live config mutated")
}
for _, headers := range []map[string]interface{}{{"Missing": maskedSecret}, {"X-Gateway-Token": maskedSecret, "x-gateway-token": maskedSecret}} {
if transformHeaderSecrets(headers, map[string]interface{}{"X-Gateway-Token": "private-token"}, true) == nil {
t.Fatal("ambiguous or missing header accepted")
}
}
}
func TestEmptyAuditHeadersRoundTripPreservesPolicy(t *testing.T) {
main := config.OpenAIConfig{Headers: map[string]string{"Authorization": "Bearer main"}}
for _, identity := range []string{"", "https://audit.invalid"} {
hitl := config.HitlConfig{AuditModel: config.OpenAIConfig{BaseURL: identity, Headers: map[string]string{}}}
before := hitl.AuditModelEffective(main).Headers["Authorization"]
var doc yaml.Node
yaml.Unmarshal([]byte("hitl: {}\n"), &doc)
updateHitlConfig(&doc, hitl)
raw, _ := yaml.Marshal(&doc)
var saved config.Config
if err := yaml.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if after := saved.Hitl.AuditModelEffective(main).Headers["Authorization"]; after != before {
t.Fatalf("round trip changed header policy: %q -> %q", before, after)
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package handler
import (
"fmt"
"strings"
)
// Any private header name may carry a credential. A fixed list such as
// Authorization/X-Api-Key cannot safely classify a user-defined header map.
func transformHeaderSecrets(headers, stored map[string]interface{}, restoring bool) error {
seen := map[string]bool{}
for name, value := range headers {
key := strings.ToLower(strings.TrimSpace(name))
if restoring && seen[key] {
return fmt.Errorf("重复的请求头名称")
}
seen[key] = true
text, ok := value.(string)
if !ok {
continue
}
if !restoring {
if text != "" {
headers[name] = maskedSecret
}
continue
}
if text != maskedSecret {
continue
}
found := ""
count := 0
for oldName, oldValue := range stored {
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(name)) {
if oldText, ok := oldValue.(string); ok {
found = oldText
count++
}
}
}
if count != 1 || found == "" || found == maskedSecret {
return fmt.Errorf("没有唯一的已保存请求头凭据,请填写新的值")
}
headers[name] = found
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"cyberstrike-ai/internal/config"
"fmt"
"strings"
)
// resolveProbeHeaders restores only explicitly scoped, same-target secrets.
// Missing/ambiguous markers fail before any outbound HTTP request is made.
func (h *ConfigHandler) resolveProbeHeaders(req TestOpenAIRequest) (map[string]string, error) {
needsStored := false
seen := map[string]bool{}
for name, value := range req.Headers {
key := strings.ToLower(strings.TrimSpace(name))
if seen[key] {
return nil, fmt.Errorf("重复的请求头名称")
}
seen[key] = true
if strings.Contains(value, maskedSecret) {
needsStored = true
}
}
if !needsStored {
return config.ExpandHeaderMap(req.Headers), nil
}
h.mu.RLock()
defer h.mu.RUnlock()
if h.config == nil {
return nil, fmt.Errorf("没有已保存的请求头凭据")
}
var stored config.OpenAIConfig
switch {
case req.ChannelID != "":
ch, ok := h.config.AI.Channels[req.ChannelID]
if !ok {
return nil, fmt.Errorf("未知通道,无法恢复请求头凭据")
}
stored = ch.ToOpenAIConfig()
if req.CredentialScope == "hitlAudit" {
stored = h.config.Hitl.AuditModelEffective(stored)
} else if req.CredentialScope != "" && req.CredentialScope != "openai" {
return nil, fmt.Errorf("请求头凭据范围不匹配")
}
case req.CredentialScope == "hitlAudit":
stored = h.config.Hitl.AuditModelEffective(h.config.OpenAI)
case req.CredentialScope == "openai":
stored = h.config.OpenAI
default:
return nil, fmt.Errorf("请指定通道或模型凭据范围以恢复请求头")
}
if probeHeaderProvider(req.Provider) != probeHeaderProvider(stored.Provider) || probeHeaderURL(req.Provider, req.BaseURL) != probeHeaderURL(stored.Provider, stored.BaseURL) || strings.TrimSpace(req.APIKey) != strings.TrimSpace(stored.APIKey) {
return nil, fmt.Errorf("模型身份或地址已改变,请重新填写请求头凭据")
}
out := make(map[string]string, len(req.Headers))
for name, value := range req.Headers {
if strings.Contains(value, maskedSecret) {
if value != maskedSecret {
return nil, fmt.Errorf("无效的请求头凭据占位符")
}
found := ""
count := 0
for savedName, savedValue := range stored.Headers {
if strings.EqualFold(strings.TrimSpace(savedName), strings.TrimSpace(name)) {
found = savedValue
count++
}
}
if count != 1 || strings.TrimSpace(found) == "" || strings.Contains(found, maskedSecret) {
return nil, fmt.Errorf("没有唯一的已保存请求头凭据")
}
value = found
}
out[name] = value
}
return config.ExpandHeaderMap(out), nil
}
func probeHeaderProvider(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return "openai"
}
return provider
}
func probeHeaderURL(provider, raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
if probeHeaderProvider(provider) == "claude" {
return "https://api.anthropic.com"
}
return "https://api.openai.com/v1"
}
return raw
}
+9
View File
@@ -44,6 +44,15 @@ func transformSecretTree(tree, current interface{}, restoring bool) error {
case map[string]interface{}:
previous, _ := current.(map[string]interface{})
for key, value := range node {
if key == "headers" {
if headers, ok := value.(map[string]interface{}); ok {
stored, _ := previous[key].(map[string]interface{})
if err := transformHeaderSecrets(headers, stored, restoring); err != nil {
return err
}
continue
}
}
text, isString := value.(string)
if secretConfigKey(key) && isString {
if !restoring && text != "" {
@@ -0,0 +1,89 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"cyberstrike-ai/internal/config"
)
// TestTestOpenAIForwardsCustomHeaders 校验「测试连接」与真实对话一样会带上渠道自定义请求头:
// 静态头原样发送,%session_id% 用探测值填充,Authorization 仍按 api_key 生成。
func TestTestOpenAIForwardsCustomHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
var gotSession, gotFlag, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSession = r.Header.Get("X-Opencode-Session")
gotFlag = r.Header.Get("X-Custom-Flag")
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chatcmpl-test","model":"test-model","choices":[{"message":{"role":"assistant","content":"pong"}}]}`))
}))
defer srv.Close()
h := &ConfigHandler{config: &config.Config{}, logger: zap.NewNop()}
body := `{"provider":"openai","base_url":"` + srv.URL + `/v1","api_key":"test-key","model":"test-model",` +
`"headers":{"X-Opencode-Session":"%session_id%","X-Custom-Flag":"hello"}}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/config/test-openai", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.TestOpenAI(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["success"] != true {
t.Fatalf("probe failed: %s", w.Body.String())
}
if gotSession != "connect-test" {
t.Fatalf("X-Opencode-Session = %q, want connect-test", gotSession)
}
if gotFlag != "hello" {
t.Fatalf("X-Custom-Flag = %q, want hello", gotFlag)
}
if gotAuth != "Bearer test-key" {
t.Fatalf("Authorization = %q", gotAuth)
}
}
// TestTestOpenAIWithoutHeadersKeepsBuiltinAuth 确认未配置 headers 时行为不变。
func TestTestOpenAIWithoutHeadersKeepsBuiltinAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"x","model":"m","choices":[{"message":{"role":"assistant","content":"pong"}}]}`))
}))
defer srv.Close()
h := &ConfigHandler{config: &config.Config{}, logger: zap.NewNop()}
body := `{"provider":"openai","base_url":"` + srv.URL + `/v1","api_key":"plain-key","model":"m"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/config/test-openai", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.TestOpenAI(c)
if gotAuth != "Bearer plain-key" {
t.Fatalf("Authorization = %q, want Bearer plain-key", gotAuth)
}
}
+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 {
+2 -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
return customHeaderHTTPClient(&cloned, cfg)
}
+57
View File
@@ -0,0 +1,57 @@
package openai
import (
"cyberstrike-ai/internal/config"
"fmt"
"net/http"
"net/url"
"strings"
)
// customHeaderHTTPClient does not mutate shared clients. Custom headers can
// contain credentials under arbitrary names, so none may follow a cross-origin
// redirect (including HTTPS downgrades). Checking only in the transport is too
// late: net/http also copies private headers into redirected requests.
func customHeaderHTTPClient(base *http.Client, cfg *config.OpenAIConfig) *http.Client {
if base == nil {
base = http.DefaultClient
}
if cfg == nil || len(cfg.Headers) == 0 {
return base
}
cloned := *base
previous := base.CheckRedirect
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if previous != nil {
if err := previous(req, via); err != nil {
return err
}
} else if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if len(via) == 0 || !sameHeaderOrigin(req.URL, via[0].URL) {
return fmt.Errorf("cross-origin redirect blocked for model custom headers")
}
return nil
}
return &cloned
}
func sameHeaderOrigin(a, b *url.URL) bool {
if a == nil || b == nil {
return false
}
port := func(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if strings.EqualFold(u.Scheme, "https") {
return "443"
}
if strings.EqualFold(u.Scheme, "http") {
return "80"
}
return ""
}
return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Hostname(), b.Hostname()) && port(a) == port(b)
}
+131
View File
@@ -0,0 +1,131 @@
package openai
import (
"context"
"cyberstrike-ai/internal/config"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestCustomHeadersDoNotCrossRedirectOrigin(t *testing.T) {
for _, kind := range []string{"eino", "builtin"} {
t.Run(kind, func(t *testing.T) {
targetCalls := 0
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetCalls++; w.Write([]byte(`{}`)) }))
defer dest.Close()
for _, mode := range []string{"port", "host", "downgrade"} {
t.Run(mode, func(t *testing.T) {
target := dest.URL
if mode == "host" {
target = strings.Replace(target, "127.0.0.1", "localhost", 1)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, 302) })
var src *httptest.Server
if mode == "downgrade" {
src = httptest.NewTLSServer(handler)
} else {
src = httptest.NewServer(handler)
}
defer src.Close()
cfg := &config.OpenAIConfig{BaseURL: src.URL, APIKey: "test-key", Headers: map[string]string{"Authorization": "Bearer test-private", "X-Private-Key": "private"}}
var err error
if kind == "eino" {
var resp *http.Response
resp, err = NewEinoHTTPClient(cfg, src.Client()).Get(src.URL)
if resp != nil {
resp.Body.Close()
}
} else {
var out interface{}
err = NewClient(cfg, src.Client(), nil).ChatCompletion(context.Background(), map[string]string{}, &out)
}
if err == nil || !strings.Contains(err.Error(), "cross-origin") {
t.Fatalf("expected blocked redirect: %v", err)
}
if targetCalls != 0 {
t.Fatalf("credentials could reach other origin: %d requests", targetCalls)
}
})
}
})
}
}
func TestCustomHeadersSameOriginRedirectAndClientIsolation(t *testing.T) {
finalCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/final" {
http.Redirect(w, r, "/final", 307)
return
}
finalCalls++
if r.Header.Get("Authorization") != "Bearer custom" || r.Header.Get("X-Private-Key") != "private" {
t.Error("same-origin headers lost")
}
w.Write([]byte(`{}`))
}))
defer srv.Close()
cfg := &config.OpenAIConfig{BaseURL: srv.URL, APIKey: "key", Headers: map[string]string{"Authorization": "Bearer custom", "X-Private-Key": "private"}}
base := srv.Client()
callbacks := 0
base.CheckRedirect = func(req *http.Request, via []*http.Request) error { callbacks++; return nil }
resp, err := NewEinoHTTPClient(cfg, base).Get(srv.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var out interface{}
if err = NewClient(cfg, base, nil).ChatCompletion(context.Background(), map[string]string{}, &out); err != nil {
t.Fatal(err)
}
if finalCalls != 2 || callbacks != 2 {
t.Fatalf("calls=%d callbacks=%d", finalCalls, callbacks)
}
// Existing callbacks are preserved; they can decline a redirect without a request.
base.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err = NewEinoHTTPClient(cfg, base).Get(srv.URL)
if err != nil || resp.StatusCode != 307 {
t.Fatalf("callback not preserved: %v", err)
}
resp.Body.Close()
if customHeaderHTTPClient(base, nil) != base {
t.Fatal("no-header client changed")
}
// RoundTrip must not mutate a request owned by the caller.
req, _ := http.NewRequest("GET", srv.URL+"/final", nil)
resp, err = NewEinoHTTPClient(cfg, base).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if req.Header.Get("Authorization") != "" {
t.Fatal("request mutated")
}
}
func TestHeaderRedirectPolicyLimitsAndCallbackMutation(t *testing.T) {
cfg := &config.OpenAIConfig{Headers: map[string]string{"X-Private": "value"}}
req, _ := http.NewRequest("GET", "https://example.com:443/next", nil)
first, _ := http.NewRequest("GET", "https://example.com/start", nil)
client := customHeaderHTTPClient(nil, cfg)
if err := client.CheckRedirect(req, []*http.Request{first}); err != nil {
t.Fatal(err)
}
if client.CheckRedirect(req, make([]*http.Request, 10)) == nil {
t.Fatal("redirect loop limit lost")
}
base := &http.Client{CheckRedirect: func(r *http.Request, _ []*http.Request) error {
r.URL, _ = url.Parse("https://other.invalid")
return nil
}}
if customHeaderHTTPClient(base, cfg).CheckRedirect(req, []*http.Request{first}) == nil {
t.Fatal("callback rewrote destination across boundary")
}
sentinel := fmt.Errorf("custom decline")
base.CheckRedirect = func(*http.Request, []*http.Request) error { return sentinel }
if customHeaderHTTPClient(base, cfg).CheckRedirect(req, nil) != sentinel {
t.Fatal("custom error lost")
}
}
+8 -4
View File
@@ -115,9 +115,10 @@ 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)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return fmt.Errorf("call openai api: %w", err)
}
@@ -207,9 +208,10 @@ 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)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return "", fmt.Errorf("call openai api: %w", err)
}
@@ -357,9 +359,10 @@ 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)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return "", nil, "", fmt.Errorf("call openai api: %w", err)
}
@@ -572,8 +575,9 @@ 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)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return nil, fmt.Errorf("call openai models api: %w", err)
}
+118
View File
@@ -0,0 +1,118 @@
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)
}
cloned := req.Clone(req.Context())
applyCustomHeaders(cloned, rt.cfg)
return base.RoundTrip(cloned)
}
+162
View File
@@ -0,0 +1,162 @@
package openai
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"cyberstrike-ai/internal/config"
)
func TestApplyCustomHeadersOverridesBuiltinAndSkipsEmpty(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer builtin")
applyCustomHeaders(req, &config.OpenAIConfig{Headers: map[string]string{
"Authorization": "Bearer custom",
"X-Api-Key": "secret",
"User-Agent": "cyberstrike-ai/1.0",
"X-Empty": "",
" ": "dropped",
}})
if got := req.Header.Get("Authorization"); got != "Bearer custom" {
t.Fatalf("Authorization = %q, want custom override", got)
}
if got := req.Header.Get("X-Api-Key"); got != "secret" {
t.Fatalf("X-Api-Key = %q", got)
}
if got := req.Header.Get("User-Agent"); got != "cyberstrike-ai/1.0" {
t.Fatalf("User-Agent = %q", got)
}
if got := req.Header.Get("X-Empty"); got != "" {
t.Fatalf("empty header value must be skipped, got %q", got)
}
}
func TestApplyCustomHeadersNilConfigIsNoop(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil)
applyCustomHeaders(req, nil)
if got := req.Header.Get("Authorization"); got != "" {
t.Fatalf("unexpected Authorization %q", got)
}
}
func TestHeadersRoundTripperInjectsHeaders(t *testing.T) {
var auth, provider string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
provider = r.Header.Get("X-Provider")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := &config.OpenAIConfig{Headers: map[string]string{
"Authorization": "Bearer from-headers",
"X-Provider": "opencode",
}}
client := NewEinoHTTPClient(cfg, srv.Client())
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat/completions", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
resp.Body.Close()
if auth != "Bearer from-headers" {
t.Fatalf("Authorization = %q", auth)
}
if provider != "opencode" {
t.Fatalf("X-Provider = %q", provider)
}
}
func TestExpandHeaderMapEnv(t *testing.T) {
t.Setenv("TEST_CS_HEADER_TOKEN", "tok-123")
got := config.ExpandHeaderMap(map[string]string{
"X-Api-Key": "${TEST_CS_HEADER_TOKEN}",
"X-Default": "${TEST_CS_MISSING_VAR:-fallback}",
})
if got["X-Api-Key"] != "tok-123" {
t.Fatalf("X-Api-Key = %q", got["X-Api-Key"])
}
if got["X-Default"] != "fallback" {
t.Fatalf("X-Default = %q", got["X-Default"])
}
if config.ExpandHeaderMap(nil) != nil {
t.Fatalf("nil headers should stay nil")
}
}
func TestSessionHeaderPlaceholderRendering(t *testing.T) {
cfg := &config.OpenAIConfig{Headers: map[string]string{
"x-opencode-session": "%session_id%",
"X-Prefixed": "pfx-%session_id%",
"X-Fixed": "static",
}}
ctx := WithSessionID(context.Background(), "conv-abc-123")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://example.com/v1/chat/completions", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
applyCustomHeaders(req, cfg)
if got := req.Header.Get("x-opencode-session"); got != "conv-abc-123" {
t.Fatalf("session header = %q, want conv-abc-123", got)
}
if got := req.Header.Get("X-Prefixed"); got != "pfx-conv-abc-123" {
t.Fatalf("prefixed header = %q", got)
}
if got := req.Header.Get("X-Fixed"); got != "static" {
t.Fatalf("fixed header = %q", got)
}
}
func TestSessionHeaderSkippedWithoutSession(t *testing.T) {
cfg := &config.OpenAIConfig{Headers: map[string]string{
"x-opencode-session": "%session_id%",
"X-Fixed": "static",
}}
req, _ := http.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil)
applyCustomHeaders(req, cfg)
if got := req.Header.Get("x-opencode-session"); got != "" {
t.Fatalf("placeholder must be skipped without session, got %q", got)
}
if got := req.Header.Get("X-Fixed"); got != "static" {
t.Fatalf("fixed header must still be sent, got %q", got)
}
}
func TestSessionIDContextRoundTrip(t *testing.T) {
if got := SessionIDFromContext(context.Background()); got != "" {
t.Fatalf("empty context should yield empty id, got %q", got)
}
if got := SessionIDFromContext(WithSessionID(context.Background(), " ")); got != "" {
t.Fatalf("blank id must be ignored, got %q", got)
}
ctx := WithSessionID(context.Background(), " conv-1 ")
if got := SessionIDFromContext(ctx); got != "conv-1" {
t.Fatalf("id = %q, want conv-1", got)
}
}
func TestRenderHeaderValueCases(t *testing.T) {
cases := []struct {
value string
sid string
want string
ok bool
}{
{"static", "s1", "static", true},
{"%session_id%", "s1", "s1", true},
{"a-%session_id%-b", "s1", "a-s1-b", true},
{"%session_id%", "", "", false},
}
for _, c := range cases {
got, ok := renderHeaderValue(c.value, c.sid)
if ok != c.ok || got != c.want {
t.Fatalf("renderHeaderValue(%q,%q) = (%q,%v), want (%q,%v)", c.value, c.sid, got, ok, c.want, c.ok)
}
}
}
@@ -0,0 +1,23 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const source=fs.readFileSync('web/static/js/settings.js','utf8');
const code=source.slice(source.indexOf('function collectHitlAuditModelEffectiveConfig()'),source.indexOf('async function testHitlAuditModelConnection()'));
function collect(overrides={},savedHeaders){
const fields={'openai-provider':'openai','openai-api-key':'********','openai-base-url':'https://main.invalid','openai-model':'main',...overrides};
const c={document:{getElementById:id=>({value:fields[id]||''})},selectedAIChannelId:'main',normalizeAIChannelId:x=>x,currentConfig:{ai:{channels:{main:{headers:{Authorization:'********','X-Label':'label'}}}},hitl:{audit_model:{headers:savedHeaders}}}};
vm.createContext(c);vm.runInContext(code,c);return JSON.parse(JSON.stringify(c.collectHitlAuditModelEffectiveConfig()));
}
test('audit shares headers only when it inherits model identity',()=>{
assert.equal(collect().headers.Authorization,'********');
assert.equal(collect({'hitl-audit-model-name':'small'}).headers.Authorization,'********');
for(const [id,value] of [['hitl-audit-model-provider','openai'],['hitl-audit-model-base-url','https://audit.invalid'],['hitl-audit-model-api-key','********']])assert.deepEqual(collect({[id]:value}).headers,{});
});
test('explicit audit headers belong to audit configuration',()=>{
assert.deepEqual(collect({'hitl-audit-model-base-url':'https://audit.invalid'},{'X-Api-Key':'********'}).headers,{'X-Api-Key':'********'});
});
test('empty audit headers follow the same identity policy as omitted headers',()=>{
assert.deepEqual(collect({},{}).headers,collect().headers);
assert.deepEqual(collect({'hitl-audit-model-api-key':'audit-key'},{}).headers,{});
});
+44 -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,19 @@ function collectHitlAuditModelEffectiveConfig() {
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
model: document.getElementById('openai-model')?.value.trim() || ''
};
const auditProvider = document.getElementById('hitl-audit-model-provider')?.value || '';
const auditKey = document.getElementById('hitl-audit-model-api-key')?.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 || {};
const auditHeaders = currentConfig?.hitl?.audit_model?.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: Object.keys(auditHeaders).length ? auditHeaders : ((overriddenBaseUrl || auditProvider || auditKey) ? {} : mainHeaders)
};
}
@@ -3952,7 +3990,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
@@ -3813,6 +3813,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>