Files
Plexum-kimi-provider/internal/anthropic/client_test.go
hzhang 01577ddfe8 feat: Plexum-kimi-provider v0.1 — Moonshot Kimi via coding endpoint
Plexum ProviderPlugin that serves Moonshot Kimi K2.6 ("Kimi for
Coding") via Kimi's Anthropic-compatible coding endpoint at
https://api.kimi.com/coding/v1/messages. Port of openclaw's
extensions/kimi-coding/provider-catalog.ts to Go + Plexum SDK.

Repo structure parallels Plexum-minimax-provider:
- internal/anthropic/    HTTP+SSE Anthropic Messages client, with new
                         UserAgent field (Kimi expects "claude-code/
                         0.1.0" — openclaw plugin parity)
- internal/translate/    canonical ↔ Anthropic translator (re-used
                         shape from MiniMax — no Kimi-specific quirks
                         needed for v1 plain-text path)
- cmd/plexum-kimi-provider-plugin/  ProviderPlugin entry

Declared models (Kimi server accepts all three; plugin normalizes
legacy aliases to the canonical id on the wire):
  kimi-for-coding      (current, default)
  kimi-code            (legacy alias)
  k2p5                 (legacy alias)

HostConfig: api_key (required), base_url (override), user_agent
(default "claude-code/0.1.0"), max_tokens_default (default 8192).

End-to-end verified against the live `sk-kimi-` subscription key:

1. CLI embedded turn 1:    "Hi there! I'm Kimi."
2. CLI embedded turn 2:    "I said hi, I'm Kimi." (multi-turn context OK)
3. Via gateway socket:     {"outcome":"text","text":"...pong"}
4. Via Fabric channel:     alice → bt2-clean → kimi agent → Kimi K2.6 →
                           outbound REST → reply in channel seq=15:
                           "Concise concurrency: goroutines and channels
                            make parallel code readable, safe, and
                            efficient without the usual threading
                            complexity."

Test the bidirectional channel pipeline works with a fresh provider:
Fabric (channel plugin) + Kimi (provider plugin) wired through Plexum
agentloop, MiniMax-style plugin packaging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:23:18 +01:00

144 lines
4.3 KiB
Go

package anthropic
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseSSESingleEvent(t *testing.T) {
body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m1\",\"model\":\"X\"}}\n\n"
ch := make(chan Event, 4)
if err := parseSSE(context.Background(), strings.NewReader(body), ch); err != nil {
t.Fatal(err)
}
close(ch)
events := drain(ch)
if len(events) != 1 || events[0].Type != "message_start" || events[0].Message.ID != "m1" {
t.Errorf("events = %+v", events)
}
}
func TestParseSSEMultiLineData(t *testing.T) {
// "data:" can be split across multiple lines per spec; joined with '\n'.
body := "data: {\"type\":\n" + "data: \"text_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n"
ch := make(chan Event, 4)
if err := parseSSE(context.Background(), strings.NewReader(body), ch); err != nil {
t.Fatal(err)
}
close(ch)
events := drain(ch)
if len(events) != 1 || events[0].Type != "text_delta" || events[0].Delta.Text != "hi" {
t.Errorf("events = %+v", events)
}
}
func TestParseSSEFlushTrailingWithoutBlank(t *testing.T) {
body := "data: {\"type\":\"message_stop\"}"
ch := make(chan Event, 4)
if err := parseSSE(context.Background(), strings.NewReader(body), ch); err != nil {
t.Fatal(err)
}
close(ch)
events := drain(ch)
if len(events) != 1 || events[0].Type != "message_stop" {
t.Errorf("trailing flush missed: %+v", events)
}
}
func TestStreamMessagesSendsRightHeaders(t *testing.T) {
var gotAuth, gotVer string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("authorization")
gotVer = r.Header.Get("anthropic-version")
w.Header().Set("content-type", "text/event-stream")
w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"))
}))
defer srv.Close()
c := New(srv.URL, "fake-key")
ch, err := c.StreamMessages(context.Background(), MessagesRequest{
Model: "X", MaxTokens: 10,
Messages: []Message{{Role: "user", Content: []ContentBlock{{"type": "text", "text": "hi"}}}},
})
if err != nil {
t.Fatal(err)
}
for range ch {
}
if gotAuth != "Bearer fake-key" {
t.Errorf("auth = %q", gotAuth)
}
if gotVer != DefaultAPIVersion {
t.Errorf("anthropic-version = %q", gotVer)
}
}
func TestStreamMessagesNon2xxErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":{"type":"authentication_error","message":"bad key"}}`))
}))
defer srv.Close()
c := New(srv.URL, "bad")
_, err := c.StreamMessages(context.Background(), MessagesRequest{Model: "X", MaxTokens: 10})
if err == nil || !strings.Contains(err.Error(), "401") {
t.Errorf("err = %v", err)
}
}
func TestStreamMessagesStreamErrorBecomesEvent(t *testing.T) {
// Simulate an SSE stream that errors mid-stream.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
flusher, _ := w.(http.Flusher)
w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\"}\n\n"))
flusher.Flush()
// Hijack-style mid-stream close: not really an error in HTTP terms,
// scanner just hits EOF cleanly. So no error event expected.
}))
defer srv.Close()
c := New(srv.URL, "k")
ch, err := c.StreamMessages(context.Background(), MessagesRequest{Model: "X", MaxTokens: 10})
if err != nil {
t.Fatal(err)
}
events := drain(ch)
if len(events) != 1 || events[0].Type != "message_start" {
t.Errorf("events = %+v", events)
}
}
func TestMessagesRequestMarshalShape(t *testing.T) {
req := MessagesRequest{
Model: "MiniMax-M2.7", MaxTokens: 50, Stream: true,
Messages: []Message{{Role: "user", Content: []ContentBlock{
{"type": "text", "text": "hi"},
}}},
}
raw, _ := json.Marshal(req)
// stream:true present
if !bytes.Contains(raw, []byte(`"stream":true`)) {
t.Errorf("missing stream:true in %s", raw)
}
if !bytes.Contains(raw, []byte(`"model":"MiniMax-M2.7"`)) {
t.Errorf("missing model: %s", raw)
}
}
func drain(ch <-chan Event) []Event {
var out []Event
for ev := range ch {
out = append(out, ev)
}
return out
}
// Compile-time check that io.EOF is exported so we don't accidentally
// remove the import.
var _ = io.EOF