1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "strings"
13 "time"
14)
15
16const anthropicDefaultBaseURL = "https://api.anthropic.com"
17const anthropicAPIVersion = "2023-06-01"
18
19// Stealth identity used when talking to Anthropic via subscription OAuth.
20// These values mimic the official Claude Code CLI so Anthropic's edge
21// accepts the request; diverging from them causes 429 rate_limit_error
22// or 403 on the very first request.
23const (
24 claudeCodeVersion = "2.1.75"
25 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
26)
27
28// Claude Code's canonical tool casing. When running under OAuth we must
29// advertise tool names that match this list (case-insensitive lookup),
30// because Anthropic's backend cross-checks them.
31var claudeCodeToolNames = map[string]string{
32 "read": "Read",
33 "write": "Write",
34 "edit": "Edit",
35 "bash": "Bash",
36 "grep": "Grep",
37 "glob": "Glob",
38}
39
40func toClaudeCodeToolName(name string) string {
41 if cc, ok := claudeCodeToolNames[strings.ToLower(name)]; ok {
42 return cc
43 }
44 return name
45}
46
47func fromClaudeCodeToolName(name string, tools []Tool) string {
48 lower := strings.ToLower(name)
49 for _, t := range tools {
50 if strings.ToLower(t.Name) == lower {
51 return t.Name
52 }
53 }
54 return name
55}
56
57// anthropicClient implements Client against the Anthropic Messages API.
58type anthropicClient struct {
59 apiKey string
60 baseURL string
61 oauthTok string // when non-empty, send Bearer auth instead of x-api-key
62 http *http.Client
63}
64
65// NewAnthropic creates an Anthropic client using an API key. baseURL may be empty.
66func NewAnthropic(apiKey, baseURL string) Client {
67 if baseURL == "" {
68 baseURL = anthropicDefaultBaseURL
69 }
70 return &anthropicClient{
71 apiKey: apiKey,
72 baseURL: strings.TrimRight(baseURL, "/"),
73 http: &http.Client{Timeout: 0},
74 }
75}
76
77// NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token.
78func NewAnthropicOAuth(accessToken, baseURL string) Client {
79 if baseURL == "" {
80 baseURL = anthropicDefaultBaseURL
81 }
82 return &anthropicClient{
83 oauthTok: accessToken,
84 baseURL: strings.TrimRight(baseURL, "/"),
85 http: &http.Client{Timeout: 0},
86 }
87}
88
89func (c *anthropicClient) Name() string { return "anthropic" }
90
91// ---- wire types ----
92
93type anthTextBlock struct {
94 Type string `json:"type"` // "text"
95 Text string `json:"text"`
96 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
97}
98
99type anthImageSource struct {
100 Type string `json:"type"` // "base64"
101 MediaType string `json:"media_type"`
102 Data string `json:"data"`
103}
104
105type anthImageBlock struct {
106 Type string `json:"type"` // "image"
107 Source anthImageSource `json:"source"`
108 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
109}
110
111type anthToolUseBlock struct {
112 Type string `json:"type"` // "tool_use"
113 ID string `json:"id"`
114 Name string `json:"name"`
115 Input json.RawMessage `json:"input"`
116 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
117}
118
119type anthToolResultBlock struct {
120 Type string `json:"type"` // "tool_result"
121 ToolUseID string `json:"tool_use_id"`
122 Content json.RawMessage `json:"content"` // string or array of blocks
123 IsError bool `json:"is_error,omitempty"`
124 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
125}
126
127type anthCacheCtrl struct {
128 Type string `json:"type"` // "ephemeral"
129 TTL string `json:"ttl,omitempty"`
130}
131
132type anthMessage struct {
133 Role string `json:"role"`
134 Content []interface{} `json:"content"`
135}
136
137type anthSystemBlock struct {
138 Type string `json:"type"` // "text"
139 Text string `json:"text"`
140 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
141}
142
143type anthTool struct {
144 Name string `json:"name"`
145 Description string `json:"description,omitempty"`
146 InputSchema json.RawMessage `json:"input_schema"`
147 CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
148}
149
150type anthThinking struct {
151 Type string `json:"type"` // "enabled"
152 BudgetTokens int `json:"budget_tokens"`
153}
154
155type anthRequest struct {
156 Model string `json:"model"`
157 MaxTokens int `json:"max_tokens"`
158 System []anthSystemBlock `json:"system,omitempty"`
159 Messages []anthMessage `json:"messages"`
160 Tools []anthTool `json:"tools,omitempty"`
161 Temperature *float32 `json:"temperature,omitempty"`
162 Thinking *anthThinking `json:"thinking,omitempty"`
163 Stream bool `json:"stream"`
164}
165
166// ---- request building ----
167
168func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
169 m, err := FindModel("anthropic", req.Model)
170 if err != nil {
171 return nil, err
172 }
173 maxTok := req.MaxTokens
174 if maxTok <= 0 {
175 maxTok = m.MaxOutput
176 }
177
178 out := &anthRequest{
179 Model: req.Model,
180 MaxTokens: maxTok,
181 Temperature: req.Temperature,
182 Stream: true,
183 }
184
185 // System prompt assembly differs between api-key and OAuth modes.
186 // OAuth requests MUST begin with the Claude Code identity line or
187 // Anthropic rejects them (429 rate_limit_error with zero tokens used).
188 //
189 // Cache budget: anthropic caps cache_control to 4 breakpoints per
190 // request. We spend them on:
191 // 1. claude-code identity (OAuth only; stable forever)
192 // 2. user system prompt (changes per-session at most)
193 // 3. last tool definition (tools change rarely)
194 // 4. last message block (advances every turn)
195 //
196 // The identity line gets its OWN cache_control so the prefix
197 // [identity] is cacheable independently of the user system
198 // prompt. Without that, the cache prefix starts after block 2
199 // and any drift in the user prompt (e.g. the Current date
200 // line flipping at midnight) invalidates everything, including
201 // the 17 identity tokens we have to re-send every request
202 // forever.
203 if c.oauthTok != "" {
204 out.System = []anthSystemBlock{{
205 Type: "text",
206 Text: claudeCodeIdentity,
207 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
208 }}
209 if req.System != "" {
210 out.System = append(out.System, anthSystemBlock{
211 Type: "text",
212 Text: req.System,
213 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
214 })
215 }
216 } else if req.System != "" {
217 out.System = []anthSystemBlock{{
218 Type: "text",
219 Text: req.System,
220 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
221 }}
222 }
223
224 if req.Reasoning != "" && m.Reasoning {
225 budget := anthropicReasoningBudget(req.Reasoning)
226 if budget > 0 {
227 out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
228 // Reasoning requires max_tokens > budget.
229 if out.MaxTokens <= budget {
230 out.MaxTokens = budget + 4096
231 }
232 }
233 }
234
235 for _, t := range req.Tools {
236 name := t.Name
237 if c.oauthTok != "" {
238 name = toClaudeCodeToolName(name)
239 }
240 out.Tools = append(out.Tools, anthTool{
241 Name: name,
242 Description: t.Description,
243 InputSchema: t.Schema,
244 })
245 }
246 // Cache the last tool definition (applies cache breakpoint to the whole tools array).
247 if n := len(out.Tools); n > 0 {
248 out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"}
249 }
250
251 // Convert messages. Anthropic's wire format has only "user" and
252 // "assistant" roles; tool_result blocks live inside user messages.
253 //
254 // CRITICAL: tool_result blocks go into their OWN new user
255 // message, they are NOT merged into the preceding user message.
256 // Merging would mutate the prior user message's content array
257 // between turn N and turn N+1: turn N caches the prefix ending at
258 // [user: "read sample.ts"], turn N+1 sends
259 // [user: "read sample.ts" + tool_result=...] which is a
260 // different block sequence, busting the cache prefix match.
261 // Anthropic's API happily accepts consecutive user messages, and
262 // emitting them separately keeps each message bit-stable across
263 // turns, so the cache prefix matches for the entire history up
264 // to the newest block.
265 req.Messages = RepairOrphanedToolResults(req.Messages)
266 for _, msg := range req.Messages {
267 renameTools := c.oauthTok != ""
268 switch msg.Role {
269 case RoleUser:
270 out.Messages = append(out.Messages, anthMessage{
271 Role: "user",
272 Content: convertAnthContent(msg.Content, renameTools),
273 })
274 case RoleTool:
275 out.Messages = append(out.Messages, anthMessage{
276 Role: "user",
277 Content: convertAnthContent(msg.Content, renameTools),
278 })
279 case RoleAssistant:
280 out.Messages = append(out.Messages, anthMessage{
281 Role: "assistant",
282 Content: convertAnthContent(msg.Content, renameTools),
283 })
284 }
285 }
286
287 // Tag the LAST user message with cache_control. Spends the 4th
288 // breakpoint. For prefixes under ~1024 tokens (Anthropic's
289 // minimum cacheable block size for Opus), no cache is written.
290 tagLastUserCache(out.Messages)
291
292 return out, nil
293}
294
295// tagLastUserCache marks the last block of the most recent user
296// message. One marker; combined with identity + systemPrompt +
297// last-tool, spends Anthropic's 4-breakpoint budget.
298func tagLastUserCache(msgs []anthMessage) {
299 for i := len(msgs) - 1; i >= 0; i-- {
300 if msgs[i].Role == "user" {
301 markLastBlockEphemeral(msgs[i].Content)
302 return
303 }
304 }
305}
306
307// markLastBlockEphemeral sets CacheControl on the last entry in blocks
308// regardless of whether it's a text, image, tool_use, or tool_result.
309// Each block type carries its own CacheControl pointer so we type-
310// switch + reassign the slice element.
311func markLastBlockEphemeral(blocks []interface{}) {
312 if len(blocks) == 0 {
313 return
314 }
315 i := len(blocks) - 1
316 cc := &anthCacheCtrl{Type: "ephemeral"}
317 switch v := blocks[i].(type) {
318 case anthTextBlock:
319 v.CacheControl = cc
320 blocks[i] = v
321 case anthImageBlock:
322 v.CacheControl = cc
323 blocks[i] = v
324 case anthToolUseBlock:
325 v.CacheControl = cc
326 blocks[i] = v
327 case anthToolResultBlock:
328 v.CacheControl = cc
329 blocks[i] = v
330 }
331}
332
333func anthropicReasoningBudget(level string) int {
334 switch strings.ToLower(level) {
335 case "low":
336 return 2048
337 case "medium":
338 return 8192
339 case "high":
340 return 16384
341 default:
342 return 0
343 }
344}
345
346func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
347 out := make([]interface{}, 0, len(blocks))
348 for _, b := range blocks {
349 switch v := b.(type) {
350 case TextBlock:
351 if v.Text == "" {
352 continue
353 }
354 out = append(out, anthTextBlock{Type: "text", Text: v.Text})
355 case ImageBlock:
356 data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
357 out = append(out, anthImageBlock{
358 Type: "image",
359 Source: anthImageSource{
360 Type: "base64",
361 MediaType: mime,
362 Data: base64.StdEncoding.EncodeToString(data),
363 },
364 })
365 case ToolCallBlock:
366 args := v.Arguments
367 if len(args) == 0 || !json.Valid(args) {
368 args = json.RawMessage("{}")
369 }
370 name := v.Name
371 if renameTools {
372 name = toClaudeCodeToolName(name)
373 }
374 out = append(out, anthToolUseBlock{
375 Type: "tool_use", ID: v.ID, Name: name, Input: args,
376 })
377 case ToolResultBlock:
378 // Flatten content to a string if all text; else array of blocks.
379 content, _ := anthBuildToolResultContent(v.Content)
380 out = append(out, anthToolResultBlock{
381 Type: "tool_result",
382 ToolUseID: v.CallID,
383 Content: content,
384 IsError: v.IsError,
385 })
386 }
387 }
388 return out
389}
390
391func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
392 onlyText := true
393 var sb strings.Builder
394 for _, b := range blocks {
395 if tb, ok := b.(TextBlock); ok {
396 if sb.Len() > 0 {
397 sb.WriteString("\n")
398 }
399 sb.WriteString(tb.Text)
400 } else {
401 onlyText = false
402 break
403 }
404 }
405 if onlyText {
406 if sb.Len() == 0 {
407 return json.Marshal("")
408 }
409 return json.Marshal(sb.String())
410 }
411 // Array form: text + image blocks.
412 arr := make([]interface{}, 0, len(blocks))
413 for _, b := range blocks {
414 switch v := b.(type) {
415 case TextBlock:
416 arr = append(arr, anthTextBlock{Type: "text", Text: v.Text})
417 case ImageBlock:
418 data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
419 arr = append(arr, anthImageBlock{
420 Type: "image",
421 Source: anthImageSource{
422 Type: "base64",
423 MediaType: mime,
424 Data: base64.StdEncoding.EncodeToString(data),
425 },
426 })
427 }
428 }
429 return json.Marshal(arr)
430}
431
432// ---- streaming ----
433
434func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
435 wire, err := c.buildRequest(req)
436 if err != nil {
437 return nil, err
438 }
439 body, err := json.Marshal(wire)
440 if err != nil {
441 return nil, err
442 }
443
444 // Optional debug dump: when $ZOT_DEBUG_ANTHROPIC is a file path
445 // we append every outgoing request body to it, one JSON object
446 // per line. Useful for diffing turn N vs turn N+1 to understand
447 // why the cache prefix isn't matching.
448 if dump := os.Getenv("ZOT_DEBUG_ANTHROPIC"); dump != "" {
449 if f, derr := os.OpenFile(dump, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); derr == nil {
450 _, _ = f.Write(body)
451 _, _ = f.Write([]byte{'\n'})
452 _ = f.Close()
453 }
454 }
455
456 newReq := func() (*http.Request, error) {
457 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
458 if err != nil {
459 return nil, err
460 }
461 httpReq.Header.Set("content-type", "application/json")
462 httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
463 if c.oauthTok != "" {
464 // Claude-Code-shaped request: identical headers and values as the
465 // official CLI. Any drift triggers Anthropic's anti-abuse check and
466 // rate-limits (or outright blocks) the request.
467 httpReq.Header.Set("accept", "application/json")
468 httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
469 httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
470 httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
471 httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
472 httpReq.Header.Set("x-app", "cli")
473 // Remove x-api-key entirely by NOT setting it.
474 } else {
475 httpReq.Header.Set("accept", "text/event-stream")
476 httpReq.Header.Set("x-api-key", c.apiKey)
477 }
478 return httpReq, nil
479 }
480
481 resp, err := doStreamWithRetry(ctx, c.http, newReq)
482 if err != nil {
483 return nil, fmt.Errorf("anthropic: %w", err)
484 }
485 if resp.StatusCode != http.StatusOK {
486 b, _ := io.ReadAll(resp.Body)
487 resp.Body.Close()
488 return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
489 }
490
491 out := make(chan Event, 16)
492 go c.runStream(ctx, resp, req, out)
493 return out, nil
494}
495
496func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
497 defer close(out)
498 defer resp.Body.Close()
499
500 model, _ := FindModel("anthropic", req.Model)
501 out <- EventStart{Model: req.Model, Provider: "anthropic"}
502
503 raw := make(chan sseEvent, 16)
504 go readSSE(resp.Body, raw)
505
506 // State for assembling the assistant message. Blocks are indexed
507 // by their `index` field from Anthropic so we can preserve the
508 // interleaved order the model emitted them in (text may come
509 // before OR after tool_use; mixing both happens frequently).
510 type blockEntry struct {
511 kind string // "text" | "tool_use"
512 textBuf strings.Builder
513 toolCall ToolCallBlock
514 toolArgs strings.Builder
515 }
516 var (
517 blocks = map[int]*blockEntry{}
518 blockOrder []int // insertion order of indexes
519 activeIdx = -1
520 usage Usage
521 stop StopReason = StopEnd
522 finalErr error
523 )
524 _ = activeIdx // read-only indicator used for legacy parity
525
526 registerBlock := func(idx int, kind string) *blockEntry {
527 if be, ok := blocks[idx]; ok {
528 return be
529 }
530 be := &blockEntry{kind: kind}
531 blocks[idx] = be
532 blockOrder = append(blockOrder, idx)
533 return be
534 }
535
536 assembleMsg := func() Message {
537 content := []Content{}
538 for _, idx := range blockOrder {
539 be := blocks[idx]
540 switch be.kind {
541 case "text":
542 if be.textBuf.Len() > 0 {
543 content = append(content, TextBlock{Text: be.textBuf.String()})
544 }
545 case "tool_use":
546 tc := be.toolCall
547 args := be.toolArgs.String()
548 if args == "" {
549 args = "{}"
550 }
551 tc.Arguments = json.RawMessage(args)
552 content = append(content, tc)
553 }
554 }
555 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
556 }
557
558 sendDone := func() {
559 usage.CostUSD = ComputeCost(model, usage)
560 out <- EventUsage{Usage: usage}
561 out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
562 }
563
564 for {
565 select {
566 case <-ctx.Done():
567 stop = StopAborted
568 finalErr = ctx.Err()
569 sendDone()
570 return
571 case ev, ok := <-raw:
572 if !ok {
573 sendDone()
574 return
575 }
576 // Parse event payload based on event: type.
577 var payload map[string]json.RawMessage
578 if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
579 continue
580 }
581 switch ev.Event {
582 case "content_block_start":
583 var idx int
584 if b, ok := payload["index"]; ok {
585 _ = json.Unmarshal(b, &idx)
586 }
587 var block struct {
588 Type string `json:"type"`
589 ID string `json:"id"`
590 Name string `json:"name"`
591 Text string `json:"text"`
592 Input json.RawMessage `json:"input"`
593 }
594 if b, ok := payload["content_block"]; ok {
595 _ = json.Unmarshal(b, &block)
596 }
597 activeIdx = idx
598 switch block.Type {
599 case "tool_use":
600 name := block.Name
601 if c.oauthTok != "" {
602 name = fromClaudeCodeToolName(name, req.Tools)
603 }
604 be := registerBlock(idx, "tool_use")
605 be.toolCall = ToolCallBlock{ID: block.ID, Name: name}
606 out <- EventToolStart{ID: block.ID, Name: name}
607 case "text":
608 registerBlock(idx, "text")
609 case "thinking":
610 // not surfaced
611 }
612 case "content_block_delta":
613 var idx int
614 if b, ok := payload["index"]; ok {
615 _ = json.Unmarshal(b, &idx)
616 }
617 var d struct {
618 Type string `json:"type"`
619 Text string `json:"text"`
620 PartialJSON string `json:"partial_json"`
621 Thinking string `json:"thinking"`
622 }
623 if b, ok := payload["delta"]; ok {
624 _ = json.Unmarshal(b, &d)
625 }
626 switch d.Type {
627 case "text_delta":
628 if be, ok := blocks[idx]; ok && be.kind == "text" {
629 be.textBuf.WriteString(d.Text)
630 }
631 out <- EventTextDelta{Delta: d.Text}
632 case "input_json_delta":
633 if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
634 be.toolArgs.WriteString(d.PartialJSON)
635 out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON}
636 }
637 case "thinking_delta":
638 // Not surfaced in v1.
639 }
640 case "content_block_stop":
641 var idx int
642 if b, ok := payload["index"]; ok {
643 _ = json.Unmarshal(b, &idx)
644 }
645 if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
646 out <- EventToolEnd{ID: be.toolCall.ID}
647 }
648 activeIdx = -1
649 case "message_start":
650 var m struct {
651 Message struct {
652 Usage struct {
653 InputTokens int `json:"input_tokens"`
654 OutputTokens int `json:"output_tokens"`
655 CacheReadInputTokens int `json:"cache_read_input_tokens"`
656 CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
657 } `json:"usage"`
658 } `json:"message"`
659 }
660 _ = json.Unmarshal([]byte(ev.Data), &m)
661 // Anthropic sends cumulative values on message_start and
662 // again on message_delta (refreshed), so assign, don't
663 // accumulate. Accumulating doubles cache_creation_input
664 // which can be 50-70% of cost.
665 usage.InputTokens = m.Message.Usage.InputTokens
666 usage.OutputTokens = m.Message.Usage.OutputTokens
667 usage.CacheReadTokens = m.Message.Usage.CacheReadInputTokens
668 usage.CacheWriteTokens = m.Message.Usage.CacheCreationInputTokens
669 case "message_delta":
670 var m struct {
671 Delta struct {
672 StopReason string `json:"stop_reason"`
673 } `json:"delta"`
674 Usage struct {
675 InputTokens int `json:"input_tokens"`
676 OutputTokens int `json:"output_tokens"`
677 CacheReadInputTokens int `json:"cache_read_input_tokens"`
678 CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
679 } `json:"usage"`
680 }
681 _ = json.Unmarshal([]byte(ev.Data), &m)
682 // Refresh usage from the latest cumulative totals
683 // Anthropic provides. Only apply non-zero fields in case
684 // a given delta only carries output tokens.
685 if m.Usage.InputTokens > 0 {
686 usage.InputTokens = m.Usage.InputTokens
687 }
688 if m.Usage.OutputTokens > 0 {
689 usage.OutputTokens = m.Usage.OutputTokens
690 }
691 if m.Usage.CacheReadInputTokens > 0 {
692 usage.CacheReadTokens = m.Usage.CacheReadInputTokens
693 }
694 if m.Usage.CacheCreationInputTokens > 0 {
695 usage.CacheWriteTokens = m.Usage.CacheCreationInputTokens
696 }
697 switch m.Delta.StopReason {
698 case "end_turn", "stop_sequence":
699 stop = StopEnd
700 case "max_tokens":
701 stop = StopLength
702 case "tool_use":
703 stop = StopToolUse
704 }
705 case "message_stop":
706 sendDone()
707 return
708 case "error":
709 var e struct {
710 Error struct {
711 Type string `json:"type"`
712 Message string `json:"message"`
713 } `json:"error"`
714 }
715 _ = json.Unmarshal([]byte(ev.Data), &e)
716 stop = StopError
717 finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message)
718 sendDone()
719 return
720 }
721 _ = activeIdx
722 }
723 }
724}