Why: a tool-budget stop left tool calls without results, host callbacks raced, a canceled MCP read stole the next response, and fetch re-resolved after the SSRF check. Evidence: GOWORK=off go vet ./... && go test -count=1 ./... && go test -race ./runtime/... ./adapters/native/... passed, including TestLoopBudgetToolCallCap, TestParallelHostCallbacksAreSerial, TestStdioSlowCancelThenEcho, TestPresetsAndClientForChatCompletions, TestFetchResolveChangesToLoopbackRejected, and TestFetchHostCheckRedirectStillRejectsPrivate. Impact: over-budget calls get an error result and no tool event; one Run serializes host callbacks; MCP dispatches by id; anthropic and google presets use OpenAI-compatible chat endpoints; fetch dials the address it just checked. deps/agent-intent is 3130190; error-code-system and logging-system are a3ee67b and 49b5d8b. Refs: N1, N2, N3, N4, N13, N14 Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
12 KiB
Go
448 lines
12 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Thinking levels, from off to max. Unknown values are rejected by NormalizeThinking.
|
|
var ThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
|
|
// NormalizeThinking returns the canonical level. Empty stays empty.
|
|
func NormalizeThinking(level string) (string, error) {
|
|
if level == "" {
|
|
return "", nil
|
|
}
|
|
level = strings.ToLower(strings.TrimSpace(level))
|
|
for _, known := range ThinkingLevels {
|
|
if level == known {
|
|
return level, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("llm: unknown thinking level %q", level)
|
|
}
|
|
|
|
// OpenAIReasoningEffort maps a thinking level onto OpenAI reasoning_effort.
|
|
// off and empty produce no parameter.
|
|
func OpenAIReasoningEffort(level string) string {
|
|
switch level {
|
|
case "minimal":
|
|
return "minimal"
|
|
case "low":
|
|
return "low"
|
|
case "medium":
|
|
return "medium"
|
|
case "high", "xhigh", "max":
|
|
return "high"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// AnthropicThinkingBudget maps a thinking level onto a token budget.
|
|
// off and empty disable thinking.
|
|
func AnthropicThinkingBudget(level string) int {
|
|
switch level {
|
|
case "minimal":
|
|
return 1024
|
|
case "low":
|
|
return 2048
|
|
case "medium":
|
|
return 4096
|
|
case "high":
|
|
return 8192
|
|
case "xhigh":
|
|
return 16384
|
|
case "max":
|
|
return 32768
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// GoogleThinkingBudget maps a thinking level onto Gemini thinkingBudget.
|
|
func GoogleThinkingBudget(level string) int {
|
|
switch level {
|
|
case "minimal", "low":
|
|
return 1024
|
|
case "medium":
|
|
return 4096
|
|
case "high":
|
|
return 8192
|
|
case "xhigh", "max":
|
|
return 24576
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// ModelRef is provider/model with an optional :thinking suffix.
|
|
type ModelRef struct {
|
|
Provider string
|
|
Model string
|
|
Thinking string
|
|
}
|
|
|
|
// ParseModelRef accepts "model", "provider/model", and "provider/model:level".
|
|
func ParseModelRef(spec string) (ModelRef, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return ModelRef{}, fmt.Errorf("llm: empty model ref")
|
|
}
|
|
thinking := ""
|
|
if i := strings.LastIndex(spec, ":"); i >= 0 {
|
|
thinking = spec[i+1:]
|
|
spec = spec[:i]
|
|
norm, err := NormalizeThinking(thinking)
|
|
if err != nil {
|
|
return ModelRef{}, err
|
|
}
|
|
thinking = norm
|
|
}
|
|
provider, model := "", spec
|
|
if i := strings.Index(spec, "/"); i >= 0 {
|
|
provider = spec[:i]
|
|
model = spec[i+1:]
|
|
}
|
|
if model == "" {
|
|
return ModelRef{}, fmt.Errorf("llm: model id missing in %q", spec)
|
|
}
|
|
return ModelRef{Provider: provider, Model: model, Thinking: thinking}, nil
|
|
}
|
|
|
|
// Protocol names the wire format a provider speaks.
|
|
type Protocol string
|
|
|
|
const (
|
|
ProtocolOpenAIChat Protocol = "openai-chat"
|
|
ProtocolOpenAIResponses Protocol = "openai-responses"
|
|
ProtocolAnthropic Protocol = "anthropic"
|
|
ProtocolGoogle Protocol = "google"
|
|
ProtocolAzure Protocol = "azure"
|
|
)
|
|
|
|
// Provider is one configured endpoint.
|
|
type Provider struct {
|
|
Name string `json:"name"`
|
|
Protocol Protocol `json:"protocol"`
|
|
BaseURL string `json:"baseURL"`
|
|
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
|
APIKey string `json:"-"`
|
|
}
|
|
|
|
// Presets are OpenAI-compatible or native endpoints that only need an API key.
|
|
func Presets() []Provider {
|
|
return []Provider{
|
|
{Name: "anthropic", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.anthropic.com/v1", APIKeyEnv: "ANTHROPIC_API_KEY"},
|
|
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
|
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
|
{Name: "google", Protocol: ProtocolOpenAIChat, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", APIKeyEnv: "GEMINI_API_KEY"},
|
|
{Name: "azure", Protocol: ProtocolAzure, BaseURL: "", APIKeyEnv: "AZURE_OPENAI_API_KEY"},
|
|
{Name: "deepseek", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.deepseek.com/v1", APIKeyEnv: "DEEPSEEK_API_KEY"},
|
|
{Name: "minimax", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.minimaxi.com/v1", APIKeyEnv: "MINIMAX_API_KEY"},
|
|
{Name: "openrouter", Protocol: ProtocolOpenAIChat, BaseURL: "https://openrouter.ai/api/v1", APIKeyEnv: "OPENROUTER_API_KEY"},
|
|
{Name: "groq", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY"},
|
|
}
|
|
}
|
|
|
|
// modelsFile is the on-disk shape of models.json.
|
|
type modelsFile struct {
|
|
Providers []Provider `json:"providers"`
|
|
}
|
|
|
|
// LoadModelsJSON reads custom providers. Missing file yields an empty list.
|
|
func LoadModelsJSON(path string) ([]Provider, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var file modelsFile
|
|
if err := json.Unmarshal(raw, &file); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range file.Providers {
|
|
if file.Providers[i].APIKey == "" && file.Providers[i].APIKeyEnv != "" {
|
|
file.Providers[i].APIKey = os.Getenv(file.Providers[i].APIKeyEnv)
|
|
}
|
|
}
|
|
return file.Providers, nil
|
|
}
|
|
|
|
// Lookup finds a preset or a custom provider by name.
|
|
func Lookup(name string, custom []Provider) (Provider, bool) {
|
|
for _, p := range custom {
|
|
if p.Name == name {
|
|
return p, true
|
|
}
|
|
}
|
|
for _, p := range Presets() {
|
|
if p.Name == name {
|
|
if p.APIKey == "" {
|
|
p.APIKey = os.Getenv(p.APIKeyEnv)
|
|
}
|
|
return p, true
|
|
}
|
|
}
|
|
return Provider{}, false
|
|
}
|
|
|
|
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Anthropic/Google
|
|
// 协议当前没有接到客户端上,返回明确错误而不是发到错误的路径。
|
|
func ClientFor(p Provider) (*Client, error) {
|
|
switch p.Protocol {
|
|
case ProtocolOpenAIChat:
|
|
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey}, nil
|
|
default:
|
|
return nil, fmt.Errorf("llm: protocol %q is not implemented", p.Protocol)
|
|
}
|
|
}
|
|
|
|
func messageContent(m Message) any {
|
|
if len(m.Images) == 0 {
|
|
return m.Content
|
|
}
|
|
parts := []map[string]any{}
|
|
if m.Content != "" {
|
|
parts = append(parts, map[string]any{"type": "text", "text": m.Content})
|
|
}
|
|
for _, img := range m.Images {
|
|
url := img.URL
|
|
if url == "" && img.Data != "" {
|
|
mime := img.MIME
|
|
if mime == "" {
|
|
mime = "image/png"
|
|
}
|
|
url = "data:" + mime + ";base64," + img.Data
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]any{"url": url},
|
|
})
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// BuildBody returns the JSON body for one native protocol.
|
|
func BuildBody(protocol Protocol, model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
|
|
switch protocol {
|
|
case ProtocolOpenAIChat, ProtocolAzure:
|
|
body := map[string]any{
|
|
"model": model,
|
|
"messages": toProviderMessages(messages),
|
|
"stream": true,
|
|
}
|
|
if opts.MaxTokens > 0 {
|
|
body["max_tokens"] = opts.MaxTokens
|
|
}
|
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
|
body["reasoning_effort"] = effort
|
|
}
|
|
if len(tools) > 0 {
|
|
body["tools"] = toProviderTools(tools)
|
|
}
|
|
return json.Marshal(body)
|
|
case ProtocolOpenAIResponses:
|
|
body := map[string]any{
|
|
"model": model,
|
|
"input": responsesInput(messages),
|
|
"stream": true,
|
|
}
|
|
if opts.MaxTokens > 0 {
|
|
body["max_output_tokens"] = opts.MaxTokens
|
|
}
|
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
|
body["reasoning"] = map[string]any{"effort": effort}
|
|
}
|
|
return json.Marshal(body)
|
|
// ProtocolAnthropic 与 ProtocolGoogle 只供离线组包。
|
|
// 当前 Client 只发 OpenAI chat completions,这两支没有接到客户端上。
|
|
// 原生协议是后续阶段的事;预设表里的 anthropic / google 已改走兼容端点。
|
|
case ProtocolAnthropic:
|
|
sys, msgs := anthropicMessages(messages)
|
|
body := map[string]any{
|
|
"model": model,
|
|
"messages": msgs,
|
|
"max_tokens": opts.MaxTokens,
|
|
"stream": true,
|
|
}
|
|
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
|
|
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
|
|
if body["max_tokens"] == 0 {
|
|
body["max_tokens"] = 128000
|
|
}
|
|
if sys != "" {
|
|
body["system"] = sys
|
|
}
|
|
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
|
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
|
|
}
|
|
return json.Marshal(body)
|
|
case ProtocolGoogle:
|
|
body := map[string]any{
|
|
"contents": googleContents(messages),
|
|
}
|
|
if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
|
body["generationConfig"] = map[string]any{
|
|
"thinkingConfig": map[string]any{"thinkingBudget": budget},
|
|
}
|
|
}
|
|
return json.Marshal(body)
|
|
default:
|
|
return nil, fmt.Errorf("llm: unknown protocol %q", protocol)
|
|
}
|
|
}
|
|
|
|
func responsesInput(messages []Message) []map[string]any {
|
|
out := make([]map[string]any, 0, len(messages))
|
|
for _, m := range messages {
|
|
role := string(m.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
out = append(out, map[string]any{
|
|
"role": role,
|
|
"content": messageContent(m),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func anthropicMessages(messages []Message) (string, []map[string]any) {
|
|
var system strings.Builder
|
|
var out []map[string]any
|
|
for _, m := range messages {
|
|
if m.Role == RoleSystem {
|
|
if system.Len() > 0 {
|
|
system.WriteString("\n")
|
|
}
|
|
system.WriteString(m.Content)
|
|
continue
|
|
}
|
|
role := "user"
|
|
if m.Role == RoleAssistant {
|
|
role = "assistant"
|
|
}
|
|
content := []map[string]any{}
|
|
if m.Content != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": m.Content})
|
|
}
|
|
for _, img := range m.Images {
|
|
content = append(content, map[string]any{
|
|
"type": "image",
|
|
"source": map[string]any{
|
|
"type": "base64",
|
|
"media_type": firstNonEmpty(img.MIME, "image/png"),
|
|
"data": img.Data,
|
|
},
|
|
})
|
|
}
|
|
out = append(out, map[string]any{"role": role, "content": content})
|
|
}
|
|
return system.String(), out
|
|
}
|
|
|
|
func googleContents(messages []Message) []map[string]any {
|
|
var out []map[string]any
|
|
for _, m := range messages {
|
|
if m.Role == RoleSystem {
|
|
continue
|
|
}
|
|
role := "user"
|
|
if m.Role == RoleAssistant {
|
|
role = "model"
|
|
}
|
|
parts := []map[string]any{}
|
|
if m.Content != "" {
|
|
parts = append(parts, map[string]any{"text": m.Content})
|
|
}
|
|
for _, img := range m.Images {
|
|
parts = append(parts, map[string]any{
|
|
"inlineData": map[string]any{
|
|
"mimeType": firstNonEmpty(img.MIME, "image/png"),
|
|
"data": img.Data,
|
|
},
|
|
})
|
|
}
|
|
out = append(out, map[string]any{"role": role, "parts": parts})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if a != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ContextOverflow reports provider errors that ask the caller to compact.
|
|
func ContextOverflow(message string) bool {
|
|
m := strings.ToLower(message)
|
|
return strings.Contains(m, "context") && (strings.Contains(m, "overflow") || strings.Contains(m, "too long") || strings.Contains(m, "length"))
|
|
}
|
|
|
|
// WithRetry re-invokes base on a retryable terminal error, up to attempts
|
|
// extra tries. onEvent receives auto_retry_start and auto_retry_end.
|
|
// Context-overflow errors are returned immediately so the caller can compact.
|
|
func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc {
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
|
out := make(chan Event)
|
|
go func() {
|
|
defer close(out)
|
|
var last []Event
|
|
for try := 0; try <= attempts; try++ {
|
|
if try > 0 {
|
|
detail := fmt.Sprintf("attempt %d", try+1)
|
|
if onEvent != nil {
|
|
onEvent("auto_retry_start", detail)
|
|
}
|
|
delay := time.Duration(200*(1<<uint(try-1))) * time.Millisecond
|
|
select {
|
|
case <-ctx.Done():
|
|
emitAbort(out, "", ctx.Err())
|
|
return
|
|
case <-time.After(delay):
|
|
}
|
|
}
|
|
last = nil
|
|
for ev := range base(ctx, model, messages, tools, opts) {
|
|
last = append(last, ev)
|
|
}
|
|
overflow, retryable := false, false
|
|
for _, ev := range last {
|
|
if ev.Kind == KindError && ContextOverflow(ev.ErrorMessage) {
|
|
overflow = true
|
|
}
|
|
if ev.Kind == KindError && ev.Retryable {
|
|
retryable = true
|
|
}
|
|
}
|
|
if overflow || !retryable || try == attempts {
|
|
if try > 0 && onEvent != nil {
|
|
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
|
|
}
|
|
for _, ev := range last {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case out <- ev:
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return out
|
|
}
|
|
}
|