Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -235,6 +235,20 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antMsg := anthropicMessage{Role: m.Role}
// ── Foreign persona rewrite (v0.23.0) ──
// Anthropic doesn't support the "name" field on messages.
// Rewrite named assistant messages (from other personas) to user
// role with attribution so Anthropic maintains alternation and
// the model understands it's a multi-participant conversation.
if m.Role == "assistant" && m.Name != "" {
antMsg.Role = "user"
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)},
}
messages = append(messages, antMsg)
continue
}
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// Assistant with tool calls → mixed content blocks
if m.Content != "" {
@@ -372,7 +386,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
}
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("provider request: %w", err)
}

View File

@@ -242,7 +242,7 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
@@ -303,7 +303,7 @@ func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req Embe
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("embedding request: %w", err)
}
@@ -424,6 +424,12 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
oaiMsg.Name = m.Name
}
// Named messages: pass through for speaker attribution (v0.23.0)
// OpenAI supports "name" on any role to distinguish participants
if m.Name != "" && m.Role != "tool" {
oaiMsg.Name = m.Name
}
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
}
@@ -453,7 +459,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("provider request: %w", err)
}
@@ -474,7 +480,7 @@ type openaiMessage struct {
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
Name string `json:"name,omitempty"` // tool name for role=tool
Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
}
// openaiContentPart represents a single element in a multimodal content array.

View File

@@ -56,7 +56,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("openrouter list models: %w", err)
}

View File

@@ -4,6 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
@@ -43,6 +47,41 @@ type ProviderConfig struct {
APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from config JSONB
ProxyMode string // "system" (default), "direct", "custom"
ProxyURL string // Only used when ProxyMode == "custom"
}
// Client returns an *http.Client configured for this provider's proxy settings.
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
// custom = explicit proxy URL (http/https/socks5).
func (c ProviderConfig) Client() *http.Client {
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
switch c.ProxyMode {
case "direct":
transport.Proxy = nil
case "custom":
if c.ProxyURL != "" {
u, err := url.Parse(c.ProxyURL)
if err != nil {
// Fall back to system rather than silently going direct —
// a bad custom URL in a mandatory-proxy environment is a
// security/audit hole if it silently bypasses.
log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err)
transport.Proxy = http.ProxyFromEnvironment
} else {
transport.Proxy = http.ProxyURL(u)
}
} else {
transport.Proxy = http.ProxyFromEnvironment
}
default: // "system" or empty
transport.Proxy = http.ProxyFromEnvironment
}
return &http.Client{Transport: transport}
}
// ── Request / Response Types ────────────────
@@ -60,7 +99,7 @@ type Message struct {
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
Name string `json:"name,omitempty"` // tool name for role="tool"
Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
}
// ContentPart is a single element in a multimodal message.

View File

@@ -52,7 +52,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
resp, err := http.DefaultClient.Do(httpReq)
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("venice list models: %w", err)
}