fix: anthropic tool-call round-trip, cache zero-hit parity, round-robin load balancing

anthropic.lua v3.0.0:
- Issue 1: tool_result/tool_use round-trip
- Issue 3: thinking default OFF (opt-in via extra_body.thinking)
- Issue 4: tool_choice mapping
- Issue 5: collect_blocks preserves unknown part types
- message_stop no longer emits done=true (was overwriting tool_calls finish_reason)
- cache_read_input_tokens normalized even at 0

gemini.lua:
- transform_response was missing cachedContentTokenCount

openai.lua (Issue 6):
- transform_error handles flat envelopes, nginx HTML, bare text

chat.go mergeUsage:
- Keep PromptTokensDetails even when CachedTokens=0

scheduler.go:
- Remove sort.SliceStable by Pref; round-robin cursor is the only LB mechanism

provider.go ModelAvailable:
- Also check Pref() > prefMin, persistently failing slots exit cands

presets.go:
- 17 built-in source templates

Tests: 6 new test functions, 2 updated for new semantics
This commit is contained in:
JianFeeeee
2026-08-28 12:02:46 +08:00
parent 94cbcb6771
commit 624fd74b45
15 changed files with 1170 additions and 114 deletions

View File

@ -365,7 +365,7 @@ func (p *Provider) ModelAvailable(model string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if s, ok := p.states[model]; ok {
return s.Available()
return s.Available() && s.Pref() > prefMin
}
return true
}
@ -806,6 +806,12 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
var realChunks int
var doneSeen bool
var doneSent bool
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
// content-block ordinal, which is sparse when thinking/text blocks
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
// clients that accumulate fragments by index reassemble multi-tool
// responses correctly.
toolIdx := newToolIndexRemapper()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
@ -843,6 +849,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
if ck.Done {
doneSent = true
}
@ -917,6 +924,62 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
// tool calls, reasoning text or usage. Standard OpenAI finish reasons are
// never classified as errors, so legitimate instant-empty completions
// (finish_reason:"stop", no output) still reach the client.
// toolIndexRemapper maps per-stream tool_call fragment indices from a sparse
// upstream ordinal (e.g. Anthropic content_block index, which skips over
// thinking/text blocks) to a dense 0-based OpenAI tool_call ordinal.
type toolIndexRemapper struct {
seen map[int]int // upstream index -> dense ordinal
next int // next dense ordinal to assign
}
func newToolIndexRemapper() toolIndexRemapper {
return toolIndexRemapper{seen: make(map[int]int)}
}
func (t *toolIndexRemapper) remap(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return raw
}
// Decode into generic maps so every upstream field is preserved verbatim;
// only the index value is rewritten.
var frags []map[string]json.RawMessage
if err := json.Unmarshal(raw, &frags); err != nil || len(frags) == 0 {
return raw
}
changed := false
for i := range frags {
orig := 0
if v, ok := frags[i]["index"]; ok {
if err := json.Unmarshal(v, &orig); err != nil {
continue
}
}
mapped, ok := t.seen[orig]
if !ok {
mapped = t.next
t.next++
t.seen[orig] = mapped
}
if mapped != orig {
b, err := json.Marshal(mapped)
if err != nil {
continue
}
frags[i]["index"] = b
changed = true
}
}
if !changed {
return raw
}
out, err := json.Marshal(frags)
if err != nil {
return raw
}
return out
}
func errorOnlyChunk(ck types.UnifiedChunk) bool {
if !ck.Done || ck.FinishReason == "" {
return false