|
| 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 | +} |
0 commit comments