mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
依赖 norma v0.3.0 的 Provider.Complete。为每个 llm_profile 增加 streaming 开关:开(默认)走流式 SSE;关走真·非流式(stream:false、一次性返回完整 JSON),可绕开部分网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去 运行中的实时进度与实时 token 计数。 - agent.Config.Stream → agentcore.Options.NonStreaming = !Stream;worker / planner / mainagent / chat / goals 经 SetNonStreaming resolver 注入,任务 路由按当前激活 profile 动态取值 - llmpool.Pool / llmrec.Recorder / taskLLMRuntime 三个 Provider 包装均实现 Complete(分别带故障转移 / 录制 / 同 provider 重试与额度 failover),使 输出、token 统计、操作统计、LLM 录制四条链路在非流式下保持兼容 - db: llm_profiles 新增 streaming 列 + ALTER 补旧库(默认 true,旧配置无感) - 系统 → LLM 配置表单增加「流式输出」开关
368 lines
14 KiB
Go
368 lines
14 KiB
Go
// Package agent wires real LLM-driven planner and work agents (on top of the
|
||
// agent-core SDK) to the dual SQLite graph. See docs/ARTEX-架构设计.md
|
||
// §4.3 (planner) and §4.4 (work agent).
|
||
//
|
||
// Provider configuration is read from the environment so the system runs with
|
||
// any Anthropic- or OpenAI-format endpoint. If no key is configured, FromEnv
|
||
// returns ok=false and the exploration engine stays idle (an LLM is required).
|
||
package agent
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/llmrec"
|
||
"github.com/Autumn-27/norma/agentcore"
|
||
"github.com/Autumn-27/norma/compaction"
|
||
"github.com/Autumn-27/norma/llm"
|
||
acperm "github.com/Autumn-27/norma/permission"
|
||
)
|
||
|
||
// Config describes the LLM backend resolved from the environment.
|
||
type Config struct {
|
||
Format llm.Format
|
||
BaseURL string
|
||
APIKey string
|
||
Model string
|
||
// Proxy routes all LLM requests through the given proxy URL (http/https/socks5,
|
||
// optionally with user:pass@ credentials). Empty means direct — it does NOT
|
||
// fall back to the standard *_PROXY environment variables.
|
||
Proxy string
|
||
// RatePerSecond / RatePerMinute cap the shared request rate across ALL agents
|
||
// using the provider (0 = that window unlimited).
|
||
RatePerSecond float64
|
||
RatePerMinute float64
|
||
// ContextWindowK is the model's context window in K tokens (user-configured),
|
||
// used to size compaction thresholds. 0 = default; see CompactionWindow.
|
||
ContextWindowK int
|
||
// ThinkingType 独立控制思考「开关」字段(thinking.type):
|
||
// "" = 不发送(默认,兼容不支持该字段的模型); "disabled" = 显式关闭;
|
||
// "enabled" = 开启. 与 ReasoningEffort 完全解耦——有些接口没有 thinking 字段、
|
||
// 只靠强度参数就能激活思考,故两者可各自单独设置.
|
||
ThinkingType string
|
||
// ReasoningEffort 独立控制思考「强度」字段:
|
||
// "" = 不发送(默认); "low"/"medium"/"high"/"xhigh"/"max" = 对应强度.
|
||
// OpenAI 映射为顶层 reasoning_effort;Anthropic 映射为 output_config.effort.
|
||
ReasoningEffort string
|
||
// Stream 控制该 profile 是否使用流式(SSE)接口。true(默认)= 流式;false = 真·
|
||
// 非流式(发 stream:false,一次性拿完整 JSON,走 Provider.Complete)。非流式可绕开
|
||
// 某些网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去运行中的实时进度/实时
|
||
// token 计数。映射为 agentcore.Options.NonStreaming = !Stream。
|
||
Stream bool
|
||
}
|
||
|
||
// compaction window resolution bounds (in K tokens). Below the floor the
|
||
// threshold math (window − summary reserve − buffer) would go non-positive and
|
||
// compaction would fire every turn; above the cap it would never fire.
|
||
const (
|
||
defaultWindowK = 200 // unset → assume a 200K window (Claude default)
|
||
minWindowK = 32 // floor so effectiveWindow stays comfortably positive
|
||
maxWindowK = 1000 // cap at 1M tokens (user request)
|
||
)
|
||
|
||
// CompactionWindow returns the model context window in TOKENS for compaction
|
||
// thresholds, resolved from the user-configured size (ContextWindowK). 0/unset →
|
||
// a 200K default; otherwise clamped to [32K, 1M] so compaction stays effective.
|
||
func (c Config) CompactionWindow() int {
|
||
k := c.ContextWindowK
|
||
if k <= 0 {
|
||
k = defaultWindowK
|
||
}
|
||
if k < minWindowK {
|
||
k = minWindowK
|
||
}
|
||
if k > maxWindowK {
|
||
k = maxWindowK
|
||
}
|
||
return k * 1000
|
||
}
|
||
|
||
// compactionConfig builds the agent-core compaction config for a context window
|
||
// in tokens. agentcore.NewSession wires the summarizer (same provider) when this
|
||
// is set on Options.Compaction.
|
||
func compactionConfig(windowTokens int) *compaction.Config {
|
||
if windowTokens <= 0 {
|
||
windowTokens = defaultWindowK * 1000
|
||
}
|
||
return &compaction.Config{ContextWindow: windowTokens}
|
||
}
|
||
|
||
// FromEnv resolves the LLM provider config:
|
||
//
|
||
// ARTEX_LLM_PROVIDER = anthropic|openai (default: inferred from keys)
|
||
// ARTEX_LLM_MODEL = model id (default: per provider)
|
||
// ARTEX_LLM_BASE_URL = endpoint (optional)
|
||
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
|
||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials
|
||
func FromEnv() (Config, bool) {
|
||
prov := os.Getenv("ARTEX_LLM_PROVIDER")
|
||
anthKey := os.Getenv("ANTHROPIC_API_KEY")
|
||
oaiKey := os.Getenv("OPENAI_API_KEY")
|
||
|
||
if prov == "" {
|
||
switch {
|
||
case anthKey != "":
|
||
prov = "anthropic"
|
||
case oaiKey != "":
|
||
prov = "openai"
|
||
default:
|
||
return Config{}, false
|
||
}
|
||
}
|
||
|
||
c := Config{
|
||
BaseURL: os.Getenv("ARTEX_LLM_BASE_URL"),
|
||
Model: os.Getenv("ARTEX_LLM_MODEL"),
|
||
Proxy: strings.TrimSpace(os.Getenv("ARTEX_LLM_PROXY")),
|
||
// 默认流式;ARTEX_LLM_STREAM=false/0/off 显式关闭走非流式。
|
||
Stream: !isFalsy(os.Getenv("ARTEX_LLM_STREAM")),
|
||
}
|
||
switch prov {
|
||
case "openai":
|
||
c.Format = llm.FormatOpenAI
|
||
c.APIKey = oaiKey
|
||
if c.Model == "" {
|
||
c.Model = "gpt-4o"
|
||
}
|
||
default:
|
||
c.Format = llm.FormatAnthropic
|
||
c.APIKey = anthKey
|
||
if c.Model == "" {
|
||
c.Model = "claude-opus-4-8"
|
||
}
|
||
}
|
||
if c.APIKey == "" {
|
||
return Config{}, false
|
||
}
|
||
return c, true
|
||
}
|
||
|
||
// ConfigFrom builds a Config from UI-provided strings (provider defaults to
|
||
// anthropic; model defaults per provider). Inputs are trimmed and the base URL
|
||
// is normalized to the API base the provider expects (the provider appends the
|
||
// endpoint path itself), so a full endpoint URL is tolerated.
|
||
func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
|
||
c := Config{
|
||
Model: strings.TrimSpace(model),
|
||
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||
APIKey: strings.TrimSpace(apiKey),
|
||
Proxy: strings.TrimSpace(proxy),
|
||
Stream: true, // 默认流式;调用方按 profile 覆盖
|
||
}
|
||
switch strings.TrimSpace(provider) {
|
||
case "openai":
|
||
c.Format = llm.FormatOpenAI
|
||
// provider appends "/chat/completions"; tolerate a full endpoint URL.
|
||
c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/chat/completions"), "/")
|
||
if c.Model == "" {
|
||
c.Model = "gpt-4o"
|
||
}
|
||
default:
|
||
c.Format = llm.FormatAnthropic
|
||
// provider appends "/v1/messages".
|
||
c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/v1/messages"), "/")
|
||
if c.Model == "" {
|
||
c.Model = "claude-opus-4-8"
|
||
}
|
||
}
|
||
return c
|
||
}
|
||
|
||
// isFalsy reports whether an env-var string explicitly requests "off". Empty or
|
||
// unrecognized → false (so an unset var keeps the streaming default).
|
||
func isFalsy(s string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "0", "false", "off", "no":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Provider returns the short provider name ("anthropic"/"openai").
|
||
func (c Config) Provider() string {
|
||
if c.Format == llm.FormatOpenAI {
|
||
return "openai"
|
||
}
|
||
return "anthropic"
|
||
}
|
||
|
||
// NewProvider builds an llm.Provider from the config. When a rate is set, the
|
||
// limiter lives on the single provider instance — so planner + all workers +
|
||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||
func (c Config) NewProvider() (llm.Provider, error) {
|
||
client, err := quotaAwareHTTPClient(c.Proxy)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
lc := llm.Config{
|
||
Format: c.Format,
|
||
BaseURL: c.BaseURL,
|
||
APIKey: c.APIKey,
|
||
Model: c.Model,
|
||
HTTPClient: client,
|
||
}
|
||
// 思考开关与强度两个字段各自透传(空 = 该字段不发送)。二者解耦:
|
||
// 可只发 thinking.type、只发 effort、都发、或都不发。
|
||
lc.ThinkingType = c.ThinkingType
|
||
lc.ReasoningEffort = c.ReasoningEffort
|
||
if c.RatePerSecond > 0 || c.RatePerMinute > 0 {
|
||
lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute}
|
||
}
|
||
return llm.NewProvider(lc)
|
||
}
|
||
|
||
// IsQuotaExhaustedMessage deliberately recognizes only explicit balance,
|
||
// billing, credit, or quota-exhaustion signals. Generic 429/rate-limit text,
|
||
// authentication failures, network errors, and server failures are excluded.
|
||
var nonFailoverHTTPStatus = regexp.MustCompile(`(?:status(?:\s+code)?|http(?:\s+status)?)\s*[=:]?\s*(?:401|403|5\d\d)\b`)
|
||
var transientQuotaLimit = regexp.MustCompile(`(?i)(?:\b(?:rpm|tpm|rpd|qps)\b|quota[_\s-]*metric|rate[_\s-]*limit|too many requests|(?:requests?|tokens?)\s+(?:per|/)\s*(?:second|minute)|(?:per|/)\s*(?:second|minute)\s+(?:requests?|tokens?)|generate[_\s-]*requests[_\s-]*per[_\s-]*(?:minute|second)|tokens?[_\s-]*per[_\s-]*(?:minute|second))`)
|
||
|
||
func IsQuotaExhaustedMessage(message string) bool {
|
||
message = strings.ToLower(message)
|
||
// Authentication/authorization and provider-side 5xx failures never rotate,
|
||
// even when a gateway happens to echo a quota-looking phrase in the body.
|
||
if nonFailoverHTTPStatus.MatchString(message) {
|
||
return false
|
||
}
|
||
// Provider APIs frequently describe an ordinary rate limit as "quota
|
||
// exceeded", especially Google-style responses containing a quota metric.
|
||
// These limits recover with time and must stay on the current provider.
|
||
if transientQuotaLimit.MatchString(message) {
|
||
return false
|
||
}
|
||
markers := []string{
|
||
"insufficient_quota", "quota_exceeded", "quota exceeded", "quota exhausted",
|
||
"exceeded your current quota", "billing_hard_limit_reached",
|
||
"billing hard limit", "billing_not_active", "credit balance", "insufficient credit",
|
||
"insufficient balance", "balance is too low", "payment required", "status 402",
|
||
"余额不足", "额度不足", "额度已用尽", "欠费",
|
||
}
|
||
for _, marker := range markers {
|
||
if strings.Contains(message, marker) {
|
||
return true
|
||
}
|
||
}
|
||
// gRPC RESOURCE_EXHAUSTED is overloaded for both account quota and ordinary
|
||
// request-rate limiting. Preserve it as an explicit exhaustion signal only
|
||
// when the same error does not identify a transient rate limit.
|
||
return strings.Contains(message, "resource_exhausted") &&
|
||
!strings.Contains(message, "rate limit") &&
|
||
!strings.Contains(message, "too many requests")
|
||
}
|
||
|
||
// quotaAwareTransport preserves Norma's normal retry behavior except for a 429
|
||
// whose body explicitly says the account quota/balance is exhausted. Norma's
|
||
// retry loop treats every 429 as transient; normalizing only that response to
|
||
// 402 lets a task router fail over immediately while retaining the original
|
||
// response body for provider-specific classification and audit logs.
|
||
type quotaAwareTransport struct{ base http.RoundTripper }
|
||
|
||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
// When LLM recording is on, the Recorder puts a Capture on the context so the
|
||
// raw wire bodies can be persisted. This is the only layer that still sees
|
||
// them: norma builds the request body internally and decodes the SSE response
|
||
// before either reaches the recorder.
|
||
capt := llmrec.CaptureFrom(req.Context())
|
||
capt.SetRequest(requestBodySnapshot(req))
|
||
|
||
resp, err := t.base.RoundTrip(req)
|
||
if err != nil || resp == nil {
|
||
return resp, err
|
||
}
|
||
// Tee rather than read: a 200 is an SSE stream that must keep streaming. The
|
||
// 429 branch below reads through this wrapper, so its body lands in the
|
||
// capture before being replaced.
|
||
resp.Body = capt.TeeResponse(resp.StatusCode, resp.Body)
|
||
|
||
if resp.StatusCode != http.StatusTooManyRequests {
|
||
return resp, nil
|
||
}
|
||
body, readErr := io.ReadAll(resp.Body)
|
||
_ = resp.Body.Close()
|
||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||
resp.ContentLength = int64(len(body))
|
||
if readErr != nil {
|
||
return resp, nil
|
||
}
|
||
if IsQuotaExhaustedMessage(string(body)) {
|
||
resp.StatusCode = http.StatusPaymentRequired
|
||
resp.Status = "402 Payment Required"
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// requestBodySnapshot copies an outgoing request body without consuming it.
|
||
// norma builds every model request from a *bytes.Reader, so net/http populates
|
||
// GetBody and the copy has no effect on what gets sent.
|
||
func requestBodySnapshot(req *http.Request) string {
|
||
if req.GetBody == nil {
|
||
return ""
|
||
}
|
||
rc, err := req.GetBody()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
defer rc.Close()
|
||
b, err := io.ReadAll(rc)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||
proxy = strings.TrimSpace(proxy)
|
||
if proxy == "" {
|
||
transport.Proxy = nil // 留空=直连,不回退 HTTP_PROXY/HTTPS_PROXY 环境变量
|
||
} else {
|
||
proxyURL, err := url.Parse(proxy)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("llm: invalid proxy %q: %w", proxy, err)
|
||
}
|
||
switch proxyURL.Scheme {
|
||
case "http", "https", "socks5":
|
||
case "":
|
||
return nil, fmt.Errorf("llm: proxy %q missing scheme (use http://, https:// or socks5://)", proxy)
|
||
default:
|
||
return nil, fmt.Errorf("llm: unsupported proxy scheme %q (use http, https or socks5)", proxyURL.Scheme)
|
||
}
|
||
transport.Proxy = http.ProxyURL(proxyURL)
|
||
}
|
||
return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil
|
||
}
|
||
|
||
// TestConnection makes a minimal real completion to verify the provider/model/
|
||
// endpoint/key actually work. Returns the round-trip latency.
|
||
func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
|
||
prov, err := c.NewProvider()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
start := time.Now()
|
||
// MaxTokens 要给足:推理模型(如 deepseek-v4-pro)在给出答案前会先产出一大段
|
||
// 思考(实测对一句 "ping" 也能烧 ~2900 token)。若只给 32,模型会一直卡在"思考阶段"
|
||
// 就撞到输出上限(finish=length)、被截断,连接测试虽仍算通(err=nil)但显示成
|
||
// "已中断/length/resume" 一团糟。给足预算让它把 OK 干净吐完(finish=stop)。
|
||
// EscalateMaxTokens 保持 false:不因截断而抬额重试,避免 resume 循环空烧。
|
||
_, err = agentcore.Run(ctx, agentcore.Options{
|
||
Provider: prov,
|
||
SystemPrompt: []string{"你是连接测试。直接输出两个字符 OK 即可,不要思考、不要解释、不要别的。"},
|
||
PermissionMode: acperm.ModeBypass,
|
||
MaxTurns: 1,
|
||
MaxTokens: 8192,
|
||
NonStreaming: !c.Stream, // 用该 profile 的真实收发模式做连接测试
|
||
}, "ping")
|
||
return time.Since(start), err
|
||
}
|