Skip to content

Commit 13d3330

Browse files
committed
fix(anthropic): do not emit empty tokens and fix SSE tool calls
This fixes Claude Code compatibility Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent ad232fd commit 13d3330

2 files changed

Lines changed: 157 additions & 4 deletions

File tree

core/http/endpoints/anthropic/messages.go

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package anthropic
33
import (
44
"encoding/json"
55
"fmt"
6+
"sync"
7+
"time"
68

79
"github.com/google/uuid"
810
"github.com/labstack/echo/v4"
@@ -366,7 +368,33 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
366368
// Collect tool calls for MCP execution
367369
var collectedToolCalls []functions.FuncCallResults
368370

371+
// SSE keepalive: send comment pings every 3s until the first token arrives.
372+
// This prevents clients (e.g. Claude Code) from timing out while the model loads or processes the prompt.
373+
firstTokenReceived := make(chan struct{})
374+
keepaliveDone := make(chan struct{})
375+
go func() {
376+
defer close(keepaliveDone)
377+
ticker := time.NewTicker(3 * time.Second)
378+
defer ticker.Stop()
379+
for {
380+
select {
381+
case <-firstTokenReceived:
382+
return
383+
case <-c.Request().Context().Done():
384+
return
385+
case <-ticker.C:
386+
fmt.Fprintf(c.Response().Writer, "event: ping\ndata: {\"type\": \"ping\"}\n\n")
387+
c.Response().Flush()
388+
}
389+
}
390+
}()
391+
firstTokenOnce := sync.Once{}
392+
369393
tokenCallback := func(token string, usage backend.TokenUsage) bool {
394+
firstTokenOnce.Do(func() {
395+
close(firstTokenReceived)
396+
<-keepaliveDone // wait for keepalive goroutine to exit before writing
397+
})
370398
accumulatedContent += token
371399

372400
if shouldUseFn {
@@ -414,7 +442,7 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
414442
}
415443
}
416444

417-
if !inToolCall {
445+
if !inToolCall && token != "" {
418446
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
419447
Type: "content_block_delta",
420448
Index: intPtr(0),
@@ -433,6 +461,11 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
433461
openAIReq.Metadata = input.Metadata
434462

435463
_, tokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, func(s string, c *[]schema.Choice) {}, tokenCallback)
464+
465+
// Stop the keepalive goroutine now that inference is done
466+
firstTokenOnce.Do(func() { close(firstTokenReceived) })
467+
<-keepaliveDone
468+
436469
if err != nil {
437470
xlog.Error("Anthropic stream model inference failed", "error", err)
438471
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
@@ -445,9 +478,68 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
445478
return nil
446479
}
447480

448-
// Also check chat deltas for tool calls
449-
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 && len(collectedToolCalls) == 0 {
450-
collectedToolCalls = deltaToolCalls
481+
// Check chat deltas from C++ autoparser — when active, the raw
482+
// message is cleared and content/tool calls arrive via ChatDeltas.
483+
if len(chatDeltas) > 0 {
484+
deltaContent := functions.ContentFromChatDeltas(chatDeltas)
485+
deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas)
486+
487+
// Emit text content from ChatDeltas only when the tokenCallback
488+
// didn't already stream it (autoparser clears raw text, so
489+
// accumulatedContent will be empty in that case).
490+
if deltaContent != "" && !inToolCall && accumulatedContent == "" {
491+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
492+
Type: "content_block_delta",
493+
Index: intPtr(0),
494+
Delta: &schema.AnthropicStreamDelta{
495+
Type: "text_delta",
496+
Text: deltaContent,
497+
},
498+
})
499+
}
500+
501+
// Emit tool_use blocks from ChatDeltas
502+
if len(deltaToolCalls) > 0 && len(collectedToolCalls) == 0 {
503+
collectedToolCalls = deltaToolCalls
504+
505+
if !inToolCall && currentBlockIndex == 0 {
506+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
507+
Type: "content_block_stop",
508+
Index: intPtr(currentBlockIndex),
509+
})
510+
currentBlockIndex++
511+
inToolCall = true
512+
}
513+
for i, tc := range deltaToolCalls {
514+
toolCallID := tc.ID
515+
if toolCallID == "" {
516+
toolCallID = fmt.Sprintf("toolu_%s_%d", id, i)
517+
}
518+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
519+
Type: "content_block_start",
520+
Index: intPtr(currentBlockIndex),
521+
ContentBlock: &schema.AnthropicContentBlock{
522+
Type: "tool_use",
523+
ID: toolCallID,
524+
Name: tc.Name,
525+
},
526+
})
527+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
528+
Type: "content_block_delta",
529+
Index: intPtr(currentBlockIndex),
530+
Delta: &schema.AnthropicStreamDelta{
531+
Type: "input_json_delta",
532+
PartialJSON: tc.Arguments,
533+
},
534+
})
535+
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
536+
Type: "content_block_stop",
537+
Index: intPtr(currentBlockIndex),
538+
})
539+
currentBlockIndex++
540+
toolCallsEmitted++
541+
}
542+
}
451543
}
452544

453545
// MCP streaming tool execution: if we collected MCP tool calls, execute and loop

docs/content/integrations.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,67 @@ This section provides step-by-step instructions for configuring specific softwar
166166
After saving the configuration file, restart OpenCode for the changes to take effect.
167167

168168

169+
### Claude Code
170+
171+
[Claude Code](https://docs.anthropic.com/en/docs/claude-code) is Anthropic's official CLI tool for coding with Claude. LocalAI implements the Anthropic Messages API (`/v1/messages`), so Claude Code can be pointed directly at a LocalAI instance.
172+
173+
#### Prerequisites
174+
175+
- LocalAI must be running and accessible (either locally or on a network)
176+
- You need to know your LocalAI server's IP address/hostname and port (default is `8080`)
177+
- An API key configured in your LocalAI instance
178+
179+
#### Running Claude Code with LocalAI
180+
181+
Set the `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables to point Claude Code at your LocalAI server:
182+
183+
```bash
184+
ANTHROPIC_BASE_URL=http://127.0.0.1:8080 \
185+
ANTHROPIC_API_KEY=your-localai-api-key \
186+
claude --model your-model-name
187+
```
188+
189+
For example, if you have a Gemma model loaded:
190+
191+
```bash
192+
ANTHROPIC_BASE_URL=http://127.0.0.1:8080 \
193+
ANTHROPIC_API_KEY=your-localai-api-key \
194+
claude --model gemma-4-12B-it-GGUF
195+
```
196+
197+
You can also run a single prompt non-interactively:
198+
199+
```bash
200+
ANTHROPIC_BASE_URL=http://127.0.0.1:8080 \
201+
ANTHROPIC_API_KEY=your-localai-api-key \
202+
claude -p "list the files in /tmp" --model your-model-name
203+
```
204+
205+
#### Configuration
206+
207+
To avoid setting environment variables every time, you can add them to your shell profile (e.g., `~/.bashrc` or `~/.zshrc`):
208+
209+
```bash
210+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8080
211+
export ANTHROPIC_API_KEY=your-localai-api-key
212+
```
213+
214+
#### Verify available models
215+
216+
Check which models are available in your LocalAI instance:
217+
218+
```bash
219+
curl http://127.0.0.1:8080/v1/models
220+
```
221+
222+
Use one of the listed model IDs as the `--model` argument.
223+
224+
#### Notes
225+
226+
- Models with tool calling support (e.g., Gemma 4, Qwen 3) work best, as Claude Code relies heavily on tool use for file operations and code editing.
227+
- Larger models generally produce better results for complex coding tasks.
228+
- The Anthropic Messages API endpoint supports both streaming and non-streaming modes.
229+
169230
### Charm Crush
170231

171232
You can ask [Charm Crush](https://charm.land/crush) to generate your config by giving it this documentation's URL and your LocalAI instance URL. The configuration will look something like the following and goes in `~/.config/crush/crush.json`:

0 commit comments

Comments
 (0)