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>
162 lines
5.0 KiB
Go
162 lines
5.0 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseModelRefAndThinking(t *testing.T) {
|
|
ref, err := ParseModelRef("anthropic/claude-sonnet:high")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ref.Provider != "anthropic" || ref.Model != "claude-sonnet" || ref.Thinking != "high" {
|
|
t.Fatalf("%+v", ref)
|
|
}
|
|
if _, err := ParseModelRef("openai/gpt:nope"); err == nil {
|
|
t.Fatal("unknown thinking level must fail")
|
|
}
|
|
if AnthropicThinkingBudget("high") != 8192 || OpenAIReasoningEffort("off") != "" {
|
|
t.Fatal("thinking map")
|
|
}
|
|
p, ok := Lookup("groq", nil)
|
|
if !ok || !strings.Contains(p.BaseURL, "groq") || p.Protocol != ProtocolOpenAIChat {
|
|
t.Fatalf("preset: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestModelsJSONAndBodies(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "models.json")
|
|
raw := []byte(`{"providers":[{"name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9","apiKeyEnv":"UNUSED_KEY"}]}`)
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := LoadModelsJSON(path)
|
|
if err != nil || len(got) != 1 || got[0].Name != "local" {
|
|
t.Fatalf("%+v %v", got, err)
|
|
}
|
|
msgs := []Message{{
|
|
Role: RoleUser, Content: "see",
|
|
Images: []Image{{MIME: "image/png", Data: "aaaa"}},
|
|
}}
|
|
body, err := BuildBody(ProtocolAnthropic, "claude", append([]Message{{Role: RoleSystem, Content: "sys"}}, msgs...), nil, StreamOptions{ThinkingLevel: "low"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), "budget_tokens") || !strings.Contains(string(body), "base64") {
|
|
t.Fatalf("anthropic body: %s", body)
|
|
}
|
|
gbody, err := BuildBody(ProtocolGoogle, "gemini", msgs, nil, StreamOptions{ThinkingLevel: "max"})
|
|
if err != nil || !strings.Contains(string(gbody), "inlineData") || !strings.Contains(string(gbody), "thinkingBudget") {
|
|
t.Fatalf("google: %s %v", gbody, err)
|
|
}
|
|
rbody, err := BuildBody(ProtocolOpenAIResponses, "gpt", msgs, nil, StreamOptions{ThinkingLevel: "medium", MaxTokens: 20})
|
|
if err != nil || !strings.Contains(string(rbody), "max_output_tokens") || !strings.Contains(string(rbody), "image_url") {
|
|
t.Fatalf("responses: %s %v", rbody, err)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(rbody, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestPresetsAndClientForChatCompletions(t *testing.T) {
|
|
want := map[string]string{
|
|
"anthropic": "https://api.anthropic.com/v1",
|
|
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
}
|
|
for _, p := range Presets() {
|
|
base, ok := want[p.Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if p.Protocol != ProtocolOpenAIChat || p.BaseURL != base {
|
|
t.Fatalf("%s preset: %+v", p.Name, p)
|
|
}
|
|
}
|
|
for _, name := range []string{"openai-responses", "azure"} {
|
|
p, ok := Lookup(name, nil)
|
|
if !ok {
|
|
t.Fatal(name)
|
|
}
|
|
if _, err := ClientFor(p); err == nil {
|
|
t.Fatalf("%s should fail closed", name)
|
|
}
|
|
}
|
|
var path string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
|
}))
|
|
defer srv.Close()
|
|
client, err := ClientFor(Provider{Protocol: ProtocolOpenAIChat, BaseURL: srv.URL, APIKey: "k"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for ev := range client.Stream(context.Background(), "m", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) {
|
|
if ev.Kind == KindError {
|
|
t.Fatal(ev.Err)
|
|
}
|
|
}
|
|
if path != "/chat/completions" {
|
|
t.Fatalf("path %q", path)
|
|
}
|
|
}
|
|
|
|
func TestRetryThenSuccess(t *testing.T) {
|
|
n := 0
|
|
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
|
out := make(chan Event, 2)
|
|
n++
|
|
if n == 1 {
|
|
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "429"}
|
|
} else {
|
|
out <- Event{Kind: KindTextDelta, Text: "ok"}
|
|
out <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok"}}
|
|
}
|
|
close(out)
|
|
return out
|
|
})
|
|
var events []string
|
|
wrapped := WithRetry(base, 2, func(kind, detail string) { events = append(events, kind) })
|
|
var texts []string
|
|
for ev := range wrapped(context.Background(), "m", nil, nil, StreamOptions{}) {
|
|
if ev.Kind == KindTextDelta {
|
|
texts = append(texts, ev.Text)
|
|
}
|
|
}
|
|
if len(texts) != 1 || texts[0] != "ok" {
|
|
t.Fatalf("texts %v calls %d", texts, n)
|
|
}
|
|
if len(events) == 0 || events[0] != "auto_retry_start" {
|
|
t.Fatalf("events %v", events)
|
|
}
|
|
}
|
|
|
|
func TestContextOverflowIsNotRetried(t *testing.T) {
|
|
if !ContextOverflow("maximum context length exceeded") {
|
|
t.Fatal("expected overflow")
|
|
}
|
|
n := 0
|
|
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
|
out := make(chan Event, 1)
|
|
n++
|
|
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "context length overflow"}
|
|
close(out)
|
|
return out
|
|
})
|
|
for range WithRetry(base, 3, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("overflow must not retry, calls=%d", n)
|
|
}
|
|
}
|