Skip to content

Commit ba0013d

Browse files
author
gluck
committed
merge: a filtered or empty completion fails the send (#24)
2 parents 29b5a92 + a236f6d commit ba0013d

7 files changed

Lines changed: 435 additions & 98 deletions

File tree

‎internal/provider/anthropic/anthropic.go‎

Lines changed: 54 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -476,14 +476,7 @@ func decodeNativeMessage(nm nativeMessage) message.Message {
476476
})
477477
}
478478
}
479-
switch nm.StopReason {
480-
case "end_turn", "stop":
481-
m.StopReason = message.StopEnd
482-
case "max_tokens", "length":
483-
m.StopReason = message.StopLength
484-
case "tool_use":
485-
m.StopReason = message.StopToolInvoke
486-
}
479+
m.StopReason = provider.AnthropicStopReason(nm.StopReason)
487480
if nm.Usage != nil {
488481
m.Usage = &message.Usage{
489482
InputTokens: nm.Usage.InputTokens,
@@ -1047,50 +1040,60 @@ func (a *Anthropic) Send(ctx context.Context, in provider.SendInput, bus provide
10471040
return fmt.Errorf("anthropic: %s", apiErrorMessage(resp.StatusCode, errBody))
10481041
}
10491042

1050-
nm, err := a.drainSSE(ctx, resp.Body, model, bus)
1051-
if err != nil {
1052-
// A CANCELLED TURN IS A PREMATURE CLOSE, NOT A BROKEN STREAM. What the
1053-
// accumulator holds is real: it was produced by this provider from
1054-
// this wire, so the message and its native payload agree by
1055-
// construction. Dropping it made figaro synthesise a partial from its
1056-
// OWN text accumulator -- a message with no native payload, whose
1057-
// translation had to be re-encoded and lost every signed thinking
1058-
// block. Anything else that ends a stream (a scanner fault, a
1059-
// truncated body) is still a broken stream and still drops.
1060-
if !errors.Is(err, context.Canceled) || len(nm.Content) == 0 {
1043+
return a.land(a.drainSSE(ctx, resp.Body, model, bus))(providerName, bus)
1044+
}
1045+
1046+
// land is what happens to a drained stream, and there is ONE of it: Send and
1047+
// SendWithTransport used to carry two copies of this tail, and a rule
1048+
// learned on one path was a rule the other could forget. It returns a
1049+
// function so the drain call reads as one expression at both sites.
1050+
//
1051+
// Three outcomes:
1052+
//
1053+
// - A cancelled turn is a premature close, not a broken stream. What the
1054+
// accumulator holds is real: it was produced by this provider from this
1055+
// wire, so the message and its native payload agree by construction.
1056+
// Dropping it made figaro synthesise a partial from its OWN text, a
1057+
// message with no native payload whose re-encoded translation lost every
1058+
// signed thinking block. Anything else that ends a stream (a scanner
1059+
// fault, a truncated body) is still a broken stream and still drops.
1060+
// - A stream that closed cleanly but is not a reply (nothing in it, or a
1061+
// stop reason that is not a way of finishing: refusal, content_filter,
1062+
// a word from the future) keeps whatever the model produced, marked
1063+
// StopError, and FAILS THE SEND with the wire's word. This used to land
1064+
// as a normal stop, or as nothing at all, and the aria went quiet
1065+
// (jack-work/figaro#24).
1066+
// - A reply lands as one.
1067+
func (a *Anthropic) land(nm nativeMessage, err error) func(label string, bus provider.Bus) error {
1068+
return func(label string, bus provider.Bus) error {
1069+
if err != nil {
1070+
if !errors.Is(err, context.Canceled) || len(nm.Content) == 0 {
1071+
return err
1072+
}
1073+
nm.StopReason = string(message.StopAborted)
1074+
if perr := a.handOver(nm, bus); perr != nil {
1075+
return perr
1076+
}
10611077
return err
10621078
}
1063-
nm.StopReason = string(message.StopAborted)
1064-
if perr := a.handOver(nm, bus); perr != nil {
1065-
return perr
1079+
if fault := provider.CheckCompletion(label, decodeNativeMessage(nm), nm.StopReason); fault != nil {
1080+
if len(nm.Content) == 0 {
1081+
return fault
1082+
}
1083+
if perr := a.handOver(nm, bus); perr != nil {
1084+
return perr
1085+
}
1086+
return fault
10661087
}
1067-
return err
1068-
}
1069-
if len(nm.Content) == 0 {
1070-
return nil
1071-
}
1072-
1073-
// Land the assistant message: figLog → push figaro → cache.
1074-
msg := decodeNativeMessage(nm)
1075-
if msg.Timestamp == 0 {
1076-
msg.Timestamp = time.Now().UnixMilli()
1077-
}
1078-
// THE PROVIDER DOES NOT OWN THE LOG. It hands the message to the bus and
1079-
// the fig IR side appends it: only that side has the LT, so "the fig IR
1080-
// entry exists before anything that names it" is a SHAPE rather than a
1081-
// rule five call sites had to remember.
1082-
bus.PushMessageEnd(string(msg.StopReason))
1083-
native, err := a.assistantCacheNative(nm)
1084-
if err != nil {
1085-
return fmt.Errorf("anthropic cache assistant: %w", err)
1088+
return a.handOver(nm, bus)
10861089
}
1087-
bus.PushFigaro(msg, native)
1088-
return nil
10891090
}
10901091

10911092
// handOver gives the fig IR side the message and the native payload from ONE
1092-
// accumulator. The normal close and the premature close both go through it,
1093-
// so a partial message cannot be shaped differently from a whole one.
1093+
// accumulator. The normal close, the premature close and the faulted close
1094+
// all go through it, so a partial message cannot be shaped differently from
1095+
// a whole one. THE PROVIDER DOES NOT OWN THE LOG: it hands the message to
1096+
// the bus and the fig IR side appends it, since only that side has the LT.
10941097
func (a *Anthropic) handOver(nm nativeMessage, bus provider.Bus) error {
10951098
msg := decodeNativeMessage(nm)
10961099
if msg.Timestamp == 0 {
@@ -1149,44 +1152,7 @@ func (a *Anthropic) SendWithTransport(ctx context.Context, in provider.SendInput
11491152
return fmt.Errorf("copilot: %s", apiErrorMessage(resp.StatusCode, errBody))
11501153
}
11511154

1152-
nm, err := a.drainSSE(ctx, resp.Body, model, bus)
1153-
if err != nil {
1154-
// A CANCELLED TURN IS A PREMATURE CLOSE, NOT A BROKEN STREAM. What the
1155-
// accumulator holds is real: it was produced by this provider from
1156-
// this wire, so the message and its native payload agree by
1157-
// construction. Dropping it made figaro synthesise a partial from its
1158-
// OWN text accumulator -- a message with no native payload, whose
1159-
// translation had to be re-encoded and lost every signed thinking
1160-
// block. Anything else that ends a stream (a scanner fault, a
1161-
// truncated body) is still a broken stream and still drops.
1162-
if !errors.Is(err, context.Canceled) || len(nm.Content) == 0 {
1163-
return err
1164-
}
1165-
nm.StopReason = string(message.StopAborted)
1166-
if perr := a.handOver(nm, bus); perr != nil {
1167-
return perr
1168-
}
1169-
return err
1170-
}
1171-
if len(nm.Content) == 0 {
1172-
return nil
1173-
}
1174-
1175-
msg := decodeNativeMessage(nm)
1176-
if msg.Timestamp == 0 {
1177-
msg.Timestamp = time.Now().UnixMilli()
1178-
}
1179-
// THE PROVIDER DOES NOT OWN THE LOG. It hands the message to the bus and
1180-
// the fig IR side appends it: only that side has the LT, so "the fig IR
1181-
// entry exists before anything that names it" is a SHAPE rather than a
1182-
// rule five call sites had to remember.
1183-
bus.PushMessageEnd(string(msg.StopReason))
1184-
native, err := a.assistantCacheNative(nm)
1185-
if err != nil {
1186-
return fmt.Errorf("anthropic cache assistant: %w", err)
1187-
}
1188-
bus.PushFigaro(msg, native)
1189-
return nil
1155+
return a.land(a.drainSSE(ctx, resp.Body, model, bus))("copilot", bus)
11901156
}
11911157

11921158
func (a *Anthropic) assistantCacheNative(msg nativeMessage) (provider.AssistantCache, error) {
@@ -1317,10 +1283,12 @@ func (a *Anthropic) drainSSE(ctx context.Context, body io.ReadCloser, model stri
13171283
}()
13181284
}
13191285

1286+
// message_stop without a message_delta naming a stop reason is NOT a
1287+
// clean finish, and it is not this function's place to say it was: an
1288+
// empty stop reason used to become "end_turn" here, so a cut or filtered
1289+
// stream was byte-identical to a completion downstream. The empty string
1290+
// travels, and the decoder reads it as the fault it is.
13201291
finalizeClean := func() (nativeMessage, error) {
1321-
if stopReason == "" {
1322-
stopReason = "end_turn"
1323-
}
13241292
nm.StopReason = stopReason
13251293
nm.Usage = &usage
13261294
return nm, nil
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package anthropic
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/jack-work/figaro/api/message"
15+
"github.com/jack-work/figaro/internal/provider"
16+
"github.com/jack-work/figaro/internal/store"
17+
)
18+
19+
// jack-work/figaro#24. A proxy that filters a completion answers 200 with a
20+
// healthy-looking stream: no content, or a thinking block and then a
21+
// stop_reason figaro had no case for. Every one of those landed in the IR as
22+
// a normal reply and the aria went quiet with nothing in the log to say why.
23+
// The turn must END IN ERROR, with the wire's own word for it in the text.
24+
25+
type recordingBus struct {
26+
msgs []message.Message
27+
stops []string
28+
}
29+
30+
func (b *recordingBus) PushDelta(message.Content) {}
31+
func (b *recordingBus) PushToolInvokeStart(string, string) {}
32+
func (b *recordingBus) PushToolInvokeDelta(string, string) {}
33+
func (b *recordingBus) PushToolReady(message.Content) {}
34+
func (b *recordingBus) PushMessageEnd(s string) { b.stops = append(b.stops, s) }
35+
func (b *recordingBus) PushFigaro(m message.Message, _ ...provider.AssistantCache) {
36+
b.msgs = append(b.msgs, m)
37+
}
38+
39+
type nilResolver struct{}
40+
41+
func (nilResolver) Resolve() (string, error) { return "tok", nil }
42+
func (nilResolver) Invalidate(string) error { return nil }
43+
44+
func sseBody(events ...string) string {
45+
var b strings.Builder
46+
for _, ev := range events {
47+
b.WriteString(ev)
48+
b.WriteString("\n\n")
49+
}
50+
return b.String()
51+
}
52+
53+
const (
54+
evStart = "event: message_start\ndata: " + `{"type":"message_start","message":{"id":"msg_1","role":"assistant","model":"claude-test","content":[],"stop_reason":null,"usage":{"input_tokens":3,"output_tokens":0}}}`
55+
evThinking = "event: content_block_start\ndata: " + `{"type":"content_block_start","index":0,"content_block":{"type":"thinking"}}` +
56+
"\n\nevent: content_block_delta\ndata: " + `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hm"}}` +
57+
"\n\nevent: content_block_delta\ndata: " + `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig"}}` +
58+
"\n\nevent: content_block_stop\ndata: " + `{"type":"content_block_stop","index":0}`
59+
evText = "event: content_block_start\ndata: " + `{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}` +
60+
"\n\nevent: content_block_delta\ndata: " + `{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ciao"}}` +
61+
"\n\nevent: content_block_stop\ndata: " + `{"type":"content_block_stop","index":1}`
62+
evStop = "event: message_stop\ndata: " + `{"type":"message_stop"}`
63+
)
64+
65+
func evDelta(stop string) string {
66+
return "event: message_delta\ndata: " + `{"type":"message_delta","delta":{"stop_reason":"` + stop + `"},"usage":{"output_tokens":1}}`
67+
}
68+
69+
func sendSSE(t *testing.T, body string) (*recordingBus, error) {
70+
t.Helper()
71+
be, aria := store.NewTestAria(t, "d", message.Patch{})
72+
figLog, err := be.OpenFigIR(aria)
73+
require.NoError(t, err)
74+
_, err = figLog.Append(store.Entry[message.Message]{Payload: message.Message{
75+
Role: message.RoleInput, Content: []message.Content{message.TextContent("say ciao")},
76+
}})
77+
require.NoError(t, err)
78+
rows, err := be.OpenTranslator(aria, "anthropic")
79+
require.NoError(t, err)
80+
81+
a, err := New(provider.Knobs{Model: "claude-test", MaxTokens: 64}, nilResolver{},
82+
func(string) (store.Log[[]json.RawMessage], error) { return rows, nil })
83+
require.NoError(t, err)
84+
85+
bus := &recordingBus{}
86+
err = a.SendWithTransport(context.Background(), provider.SendInput{
87+
AriaID: aria, FigLog: figLog, MaxTokens: 64,
88+
}, bus, false, func(context.Context, *provider.RequestBody) (*http.Response, error) {
89+
return &http.Response{
90+
StatusCode: http.StatusOK,
91+
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
92+
Body: io.NopCloser(strings.NewReader(body)),
93+
}, nil
94+
})
95+
return bus, err
96+
}
97+
98+
func TestAFilteredCompletionEndsTheSendInError(t *testing.T) {
99+
cases := []struct {
100+
name string
101+
body string
102+
// What the wire said, which must appear in the error so the user
103+
// learns the proxy's word for what happened.
104+
wireWord string
105+
// Whether a message still reaches the IR: content the model paid
106+
// for is kept, marked as the error it is.
107+
kept bool
108+
}{
109+
{"refusal, thinking only", sseBody(evStart, evThinking, evDelta("refusal"), evStop), "refusal", true},
110+
{"content_filter, thinking only", sseBody(evStart, evThinking, evDelta("content_filter"), evStop), "content_filter", true},
111+
{"refusal, nothing", sseBody(evStart, evDelta("refusal"), evStop), "refusal", false},
112+
{"end_turn, nothing", sseBody(evStart, evDelta("end_turn"), evStop), "end_turn", false},
113+
{"message_stop with no stop reason", sseBody(evStart, evText, evStop), "no stop reason", true},
114+
{"a stop reason from the future", sseBody(evStart, evText, evDelta("pause_turn"), evStop), "pause_turn", true},
115+
}
116+
for _, tc := range cases {
117+
t.Run(tc.name, func(t *testing.T) {
118+
bus, err := sendSSE(t, tc.body)
119+
require.Error(t, err, "the send returned nil: the turn will record a normal stop")
120+
assert.Contains(t, err.Error(), tc.wireWord)
121+
if !tc.kept {
122+
assert.Empty(t, bus.msgs, "an empty completion is not a message")
123+
return
124+
}
125+
require.Len(t, bus.msgs, 1, "the content the model produced is kept")
126+
assert.Equal(t, message.StopError, bus.msgs[0].StopReason)
127+
assert.Equal(t, []string{string(message.StopError)}, bus.stops)
128+
})
129+
}
130+
}
131+
132+
// The other side of the same instrument: a healthy stream still lands as a
133+
// healthy message, or the guard is a tautology.
134+
func TestAHealthyCompletionStillLands(t *testing.T) {
135+
for _, stop := range []string{"end_turn", "stop_sequence"} {
136+
t.Run(stop, func(t *testing.T) {
137+
bus, err := sendSSE(t, sseBody(evStart, evThinking, evText, evDelta(stop), evStop))
138+
require.NoError(t, err)
139+
require.Len(t, bus.msgs, 1)
140+
assert.Equal(t, message.StopEnd, bus.msgs[0].StopReason)
141+
})
142+
}
143+
}
144+
145+
// An SSE error event already failed the send. It must keep doing so and
146+
// carry the event's own text, since the stop reason it sets is "error" and
147+
// the message would otherwise be decoded as if it had finished.
148+
func TestAnSSEErrorEventStillFailsTheSend(t *testing.T) {
149+
bus, err := sendSSE(t, sseBody(evStart, evText,
150+
"event: error\ndata: "+`{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`))
151+
require.Error(t, err)
152+
assert.Contains(t, err.Error(), "overloaded_error")
153+
assert.Empty(t, bus.msgs)
154+
}

‎internal/provider/anthropicsdk/anthropicsdk.go‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,24 @@ func (p *Provider) Send(ctx context.Context, in provider.SendInput, bus provider
280280
}
281281
return annotateRateLimit(cleanAPIError(err), note)
282282
}
283-
if len(msg.Content) == 0 {
284-
return nil
285-
}
286283
if msg.Timestamp == 0 {
287284
msg.Timestamp = time.Now().UnixMilli()
288285
}
286+
// A STREAM THAT CLOSED CLEANLY IS NOT YET A REPLY. Nothing in it, or a
287+
// stop reason that is not a way of finishing (refusal, content_filter, a
288+
// word from the future), keeps what the model produced marked StopError
289+
// and fails the send with the wire's word. An empty completion used to
290+
// return nil here and the turn recorded a normal stop with no content
291+
// (jack-work/figaro#24).
292+
if fault := provider.CheckCompletion(providerName, msg, string(acc.StopReason)); fault != nil {
293+
if len(msg.Content) == 0 {
294+
return fault
295+
}
296+
if perr := p.handOver(msg, acc, bus); perr != nil {
297+
return perr
298+
}
299+
return fault
300+
}
289301

290302
return p.handOver(msg, acc, bus)
291303
}

0 commit comments

Comments
 (0)