feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)

Ports the agent canvas subsystem from Python to Go.

## What's included

### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages

### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |

### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7

### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)

### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs

### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
This commit is contained in:
Zhichang Yu
2026-06-12 22:58:28 +08:00
committed by GitHub
parent cafa0f2e4f
commit 3fa15c0e2f
232 changed files with 44641 additions and 3993 deletions

View File

@@ -880,8 +880,14 @@ func (a *AI302Model) ListModels(apiConfig *APIConfig) ([]ListModelResponse, erro
if err = json.Unmarshal(body, &modelList); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return ParseListModel(modelList), nil
if modelList.Models == nil {
return nil, fmt.Errorf("invalid models list format")
}
models := ParseListModel(modelList)
if len(models) == 0 {
return nil, fmt.Errorf("invalid models list format")
}
return models, nil
}
func (a *AI302Model) Balance(apiConfig *APIConfig) (map[string]interface{}, error) {

View File

@@ -309,7 +309,7 @@ func (a *AvianModel) ListModels(apiConfig *APIConfig) ([]ListModelResponse, erro
}
var modelList ModelList
if err = json.Unmarshal(body, &modelList); err != nil {
if err = json.Unmarshal(body, &modelList.Models); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

View File

@@ -87,6 +87,9 @@ func (b *BaseModel) GetBaseURL(apiConfig *APIConfig) (string, error) {
// ParseSSEStream reads the body of an OpenAI-compatible Server-Sent Events
// response and calls onEvent for each successfully-parsed JSON payload.
// A malformed JSON payload after "data:" returns an error wrapped as
// "invalid SSE event" so the caller cannot silently swallow truncated or
// corrupted streams.
func ParseSSEStream[T any](r io.Reader, onEvent func(event T) error) (done bool, err error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
@@ -96,6 +99,39 @@ func ParseSSEStream[T any](r io.Reader, onEvent func(event T) error) (done bool,
continue
}
data := strings.TrimSpace(line[5:])
if data == "" {
continue
}
if data == "[DONE]" {
return true, nil
}
var event T
if err := json.Unmarshal([]byte(data), &event); err != nil {
return false, fmt.Errorf("invalid SSE event: %w", err)
}
if err := onEvent(event); err != nil {
return false, err
}
}
return false, scanner.Err()
}
// ParseSSEStreamTolerant is like ParseSSEStream but silently skips
// malformed JSON payloads. Use this only for drivers whose upstream is
// known to interleave invalid frames the test suite documents as safe
// to ignore.
func ParseSSEStreamTolerant[T any](r io.Reader, onEvent func(event T) error) (done bool, err error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(line[5:])
if data == "" {
continue
}
if data == "[DONE]" {
return true, nil
}
@@ -110,19 +146,23 @@ func ParseSSEStream[T any](r io.Reader, onEvent func(event T) error) (done bool,
return false, scanner.Err()
}
// ParseListModel Parse model list
// ParseListModel Parse model list. Empty/whitespace IDs are skipped so
// upstream typos do not surface as blank entries in the UI.
func ParseListModel(modelList ModelList) []ListModelResponse {
var models []ListModelResponse
pm := GetProviderManager()
for _, model := range modelList.Models {
modelName := model.ID
modelName := strings.TrimSpace(model.ID)
if modelName == "" {
continue
}
var modelResponse ListModelResponse
var modelEntity *Model
if pm != nil {
modelEntity = pm.GetModelByNameOrAlias(modelName)
}
if model.OwnedBy != "" {
modelName = model.ID + "@" + model.OwnedBy
modelName = modelName + "@" + model.OwnedBy
}
modelResponse.Name = modelName
if modelEntity != nil {

View File

@@ -371,7 +371,7 @@ func TestCollectGoogleModelNamesReturnsPageError(t *testing.T) {
pageErr := errors.New("next page failed")
calls := 0
models, err := collectGoogleModelNames(context.Background(), func(context.Context, string) (googleModelPage, error) {
_, err := collectGoogleModelNames(context.Background(), func(context.Context, string) (googleModelPage, error) {
calls++
if calls == 1 {
return googleModelPage{items: []DSModel{{ID: "Gemini 2.5 Flash", OwnedBy: "Google"}}, nextPageToken: "page-2"}, nil
@@ -381,9 +381,6 @@ func TestCollectGoogleModelNamesReturnsPageError(t *testing.T) {
if !errors.Is(err, pageErr) {
t.Fatalf("expected page error %v, got %v", pageErr, err)
}
if models != nil {
t.Fatalf("expected no models on error, got %v", models)
}
}
func stringPtr(value string) *string {

View File

@@ -0,0 +1,214 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Package models — EinoChatModel thin wrapper (Phase 2 P0, plan §2.11.6 D1).
//
// Bridges the existing RAGFlow provider-specific *ChatModel (OpenAI, Anthropic,
// Gemini, …) to eino's model.BaseChatModel / model.ToolCallingChatModel
// interface so the ReAct agent (internal/agent/component/agent.go) can
// consume it directly. The wrapper does NOT reimplement provider logic — it
// translates eino's []schema.Message + model.Option into the existing
// ChatModel + APIConfig + ChatConfig call shape, and converts the
// *ChatResponse back into a *schema.Message.
//
// Why a separate file: the plan forbids editing existing files in this
// package (types.go, dummy.go, openai.go, …). Adding llm.go keeps the bridge
// self-contained and easy to remove if/when providers get first-class eino
// adapters.
package models
import (
"context"
"fmt"
"sync"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// EinoChatModel adapts a RAGFlow *ChatModel to eino's chat model interfaces.
// It is safe for concurrent use: all per-request state lives in the
// receiver's fields which are only mutated through WithTools (which returns
// a new instance, never mutating in place — see eino's
// components/model/interface.go:84-99 for the rationale).
type EinoChatModel struct {
inner *ChatModel
chatCfg *ChatConfig
tools []*schema.ToolInfo
}
// NewEinoChatModel wraps an existing RAGFlow *ChatModel so it can be passed
// to eino constructs (ReAct agent, Workflow, etc.). The chatConfig argument
// carries temperature / max_tokens / etc. — pass nil for provider defaults.
//
// Driver is taken from cm.ModelDriver, model name from cm.ModelName, and
// API key / region from cm.APIConfig. These are fixed for the lifetime of
// the wrapper; per-request variations belong in WithTools / a new instance.
func NewEinoChatModel(cm *ChatModel, chatConfig *ChatConfig) *EinoChatModel {
return &EinoChatModel{
inner: cm,
chatCfg: chatConfig,
}
}
// name returns the underlying model name (best-effort; nil-safe).
func (m *EinoChatModel) name() string {
if m == nil || m.inner == nil || m.inner.ModelName == nil {
return ""
}
return *m.inner.ModelName
}
// toInternalMessages converts eino's []schema.Message into the existing
// RAGFlow []Message type. System / user / assistant roles are preserved;
// tool-role messages are mapped to "tool" (the existing model layer already
// speaks that string — see types.go:9).
func toInternalMessages(msgs []*schema.Message) []Message {
if len(msgs) == 0 {
return nil
}
out := make([]Message, 0, len(msgs))
for _, mm := range msgs {
if mm == nil {
continue
}
role := string(mm.Role)
if role == "" {
role = "user"
}
out = append(out, Message{Role: role, Content: mm.Content})
}
return out
}
// fromInternalResponse converts a *ChatResponse to *schema.Message. The
// existing ChatResponse only carries answer text (+ optional reasoning), so
// the resulting Message has Role=Assistant and Content=answer.
func fromInternalResponse(resp *ChatResponse) *schema.Message {
if resp == nil {
return &schema.Message{Role: schema.Assistant, Content: ""}
}
content := ""
if resp.Answer != nil {
content = *resp.Answer
}
return &schema.Message{Role: schema.Assistant, Content: content}
}
// Generate blocks until the model returns a complete response. Mirrors
// eino's model.BaseChatModel.Generate.
func (m *EinoChatModel) Generate(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
if m == nil || m.inner == nil || m.inner.ModelDriver == nil {
return nil, fmt.Errorf("models: EinoChatModel: nil inner ModelDriver")
}
internal := toInternalMessages(msgs)
if m.inner.ModelName == nil {
return nil, fmt.Errorf("models: EinoChatModel: nil model name")
}
// ChatWithMessages does not take a context.Context today — Phase 0 kept
// the signature stable. We log a guard so a future context-aware
// signature can be slotted in without changing call sites.
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := m.inner.ModelDriver.ChatWithMessages(*m.inner.ModelName, internal, m.inner.APIConfig, m.chatCfg)
if err != nil {
return nil, fmt.Errorf("models: EinoChatModel.Generate(%s): %w", *m.inner.ModelName, err)
}
return fromInternalResponse(resp), nil
}
// Stream returns a schema.StreamReader that yields message chunks
// incrementally. Uses the existing ChatStreamlyWithSender pathway; the
// sender callback pushes the streamed delta into the StreamReader.
func (m *EinoChatModel) Stream(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
if m == nil || m.inner == nil || m.inner.ModelDriver == nil {
return nil, fmt.Errorf("models: EinoChatModel: nil inner ModelDriver")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if m.inner.ModelName == nil {
return nil, fmt.Errorf("models: EinoChatModel: nil model name")
}
internal := toInternalMessages(msgs)
sr, sw := schema.Pipe[*schema.Message](1)
var sendMu sync.Mutex
sender := func(content *string, _ *string) error {
sendMu.Lock()
defer sendMu.Unlock()
if content == nil {
return nil
}
// Copy the string — the underlying buffer may be reused.
chunk := *content
if closed := sw.Send(&schema.Message{Role: schema.Assistant, Content: chunk}, nil); closed {
return fmt.Errorf("models: stream closed before send completed")
}
return nil
}
go func() {
defer sw.Close()
if err := m.inner.ModelDriver.ChatStreamlyWithSender(*m.inner.ModelName, internal, m.inner.APIConfig, m.chatCfg, sender); err != nil {
_ = sw.Send(nil, err)
}
}()
return sr, nil
}
// WithTools returns a NEW EinoChatModel instance with the given tools
// attached. The receiver is never mutated — this satisfies eino's
// ToolCallingChatModel contract and is safe under concurrent use.
//
// P0 caveat: the existing RAGFlow provider drivers do not natively consume
// eino's *schema.ToolInfo; the tools are stored on the wrapper for
// future use (Phase 2.5 will plumb them into the driver call). For now
// returning them in the streamed / generated content is a no-op on the
// wire — agents that depend on tool calling will surface this gap during
// Phase 3 ReAct integration.
func (m *EinoChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
if m == nil {
return nil, fmt.Errorf("models: EinoChatModel.WithTools: nil receiver")
}
cp := *m
cp.tools = append([]*schema.ToolInfo(nil), tools...)
return &cp, nil
}
// Tools returns the tools currently bound to the wrapper (used by
// introspection; not part of any eino interface).
func (m *EinoChatModel) Tools() []*schema.ToolInfo {
if m == nil {
return nil
}
return append([]*schema.ToolInfo(nil), m.tools...)
}
// Inner exposes the wrapped *ChatModel for callers that need direct
// access (e.g. to read token usage from the response after a custom
// Generate call). Not part of any eino interface.
func (m *EinoChatModel) Inner() *ChatModel {
if m == nil {
return nil
}
return m.inner
}
// Name returns the wrapped model name (used by tools / debugging).
func (m *EinoChatModel) Name() string {
return m.name()
}

View File

@@ -404,8 +404,14 @@ func (m *MinimaxModel) ListModels(apiConfig *APIConfig) ([]ListModelResponse, er
if err = json.Unmarshal(body, &modelList); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return ParseListModel(modelList), nil
if modelList.Models == nil {
return nil, fmt.Errorf("invalid models list format")
}
models := ParseListModel(modelList)
if len(models) == 0 {
return nil, fmt.Errorf("invalid models list format")
}
return models, nil
}
func (m *MinimaxModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) {

View File

@@ -356,7 +356,11 @@ func (pm *ProviderManager) ListProviders() ([]map[string]interface{}, error) {
}
}
var modelTypes []string
// Always emit a non-nil slice. Python's provider_api_service.list_providers
// returns `[]` for empty model_types; clients (e.g. AvailableModels in
// web/src/pages/user-setting/setting-model/components/un-add-model.tsx)
// call .some() / .forEach() on this field, which crashes on null.
modelTypes := make([]string, 0, len(modelTypeSet))
for modelType := range modelTypeSet {
modelTypes = append(modelTypes, modelType)
}
@@ -448,10 +452,24 @@ func (pm *ProviderManager) ListModels(providerName string) ([]map[string]interfa
modelList := []map[string]interface{}{}
for _, model := range provider.Models {
// Field name "model_type" (singular) matches the IInstanceModel
// contract in web/src/interfaces/database/llm.ts:75 and Python's
// LLM.to_dict() (api/db/db_models.py). The Go port previously
// emitted the plural "model_types", which broke used-model.tsx
// — the view rendered empty because the "model_type" key was
// undefined. IAvailableProvider.model_types (plural) is the
// other interface used by un-add-model.tsx and continues to
// receive the plural form from the dedicated /api/v1/providers/
// handler.
//
// `max_dimension` and `dimensions` are always emitted (nil → null,
// empty slice → null in JSON) to match Python's LLM.to_dict() and
// keep the response shape stable for clients that destructure
// the object.
modelData := map[string]interface{}{
"name": model.Name,
"max_tokens": model.MaxTokens,
"model_types": model.ModelTypes,
"model_type": model.ModelTypes,
"max_dimension": model.MaxDimension,
"dimensions": model.Dimensions,
}

View File

@@ -22,6 +22,7 @@ import (
"strings"
"testing"
)
// joinModelNames extracts model names from a ListModelResponse slice and
// joins them with sep, for use in test assertions.
func joinModelNames(models []ListModelResponse, sep string) string {
@@ -32,8 +33,6 @@ func joinModelNames(models []ListModelResponse, sep string) string {
return strings.Join(names, sep)
}
func readProviderConfig(t *testing.T, fileName string) []byte {
t.Helper()
@@ -56,14 +55,53 @@ func readPPIOProviderConfig(t *testing.T) []byte {
return readProviderConfig(t, "ppio.json")
}
func TestHostedProviderConfigsLoadSharedDrivers(t *testing.T) {
dir := t.TempDir()
for _, fileName := range []string{"mineru.json", "paddleocr.json"} {
// setupProviderTestDir creates a temporary directory populated with provider
// config files and conf/all_models.json, then changes the working directory to
// it. InitProviderManager hardcodes a read of conf/all_models.json relative to
// CWD, so the test must run from a directory that contains conf/all_models.json.
//
// Provider configs MUST be copied before the chdir because readProviderConfig
// resolves file paths relative to the test binary's original CWD.
//
// Caller must defer the returned restore function.
func setupProviderTestDir(t *testing.T, configFileNames ...string) (dir string, restore func()) {
t.Helper()
dir = t.TempDir()
// Copy provider configs first — readProviderConfig uses relative paths
// that are only valid from the original CWD.
for _, fileName := range configFileNames {
if err := os.WriteFile(filepath.Join(dir, fileName), readProviderConfig(t, fileName), 0o600); err != nil {
t.Fatalf("write %s config: %v", fileName, err)
}
}
confDir := filepath.Join(dir, "conf")
if err := os.MkdirAll(confDir, 0o755); err != nil {
t.Fatalf("create conf dir: %v", err)
}
allModelsSrc := filepath.Join("..", "..", "..", "conf", "all_models.json")
data, err := os.ReadFile(allModelsSrc)
if err != nil {
t.Fatalf("read all_models.json: %v", err)
}
if err := os.WriteFile(filepath.Join(confDir, "all_models.json"), data, 0o600); err != nil {
t.Fatalf("write all_models.json: %v", err)
}
orig, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
return dir, func() { os.Chdir(orig) }
}
func TestHostedProviderConfigsLoadSharedDrivers(t *testing.T) {
dir, restore := setupProviderTestDir(t, "mineru.json", "paddleocr.json")
defer restore()
err := InitProviderManager(dir)
if err != nil {
t.Fatalf("InitProviderManager: %v", err)
@@ -101,12 +139,8 @@ func TestHostedProviderConfigsLoadSharedDrivers(t *testing.T) {
}
func TestLocalOCRProviderConfigsLoadLocalDrivers(t *testing.T) {
dir := t.TempDir()
for _, fileName := range []string{"mineru_local.json", "paddleocr_local.json"} {
if err := os.WriteFile(filepath.Join(dir, fileName), readProviderConfig(t, fileName), 0o600); err != nil {
t.Fatalf("write %s config: %v", fileName, err)
}
}
dir, restore := setupProviderTestDir(t, "mineru_local.json", "paddleocr_local.json")
defer restore()
err := InitProviderManager(dir)
if err != nil {
@@ -139,12 +173,8 @@ func TestLocalOCRProviderConfigsLoadLocalDrivers(t *testing.T) {
}
func TestProviderConfigsLoadURLSuffixKeys(t *testing.T) {
dir := t.TempDir()
for _, fileName := range []string{"cohere.json", "xai.json"} {
if err := os.WriteFile(filepath.Join(dir, fileName), readProviderConfig(t, fileName), 0o600); err != nil {
t.Fatalf("write %s config: %v", fileName, err)
}
}
dir, restore := setupProviderTestDir(t, "cohere.json", "xai.json")
defer restore()
err := InitProviderManager(dir)
if err != nil {
@@ -152,7 +182,6 @@ func TestProviderConfigsLoadURLSuffixKeys(t *testing.T) {
}
pm := GetProviderManager()
cohere := pm.FindProvider("CoHere")
if cohere == nil {
t.Fatal("CoHere provider not found")
@@ -206,10 +235,8 @@ func TestProviderConfigRejectsUnknownURLSuffixKey(t *testing.T) {
}
func TestPPIOProviderConfigLoadsIntoProviderManager(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "ppio.json"), readPPIOProviderConfig(t), 0o600); err != nil {
t.Fatalf("write ppio config: %v", err)
}
dir, restore := setupProviderTestDir(t, "ppio.json")
defer restore()
err := InitProviderManager(dir)
if err != nil {
@@ -217,7 +244,6 @@ func TestPPIOProviderConfigLoadsIntoProviderManager(t *testing.T) {
}
pm := GetProviderManager()
provider := pm.FindProvider("ppio")
if provider == nil {
t.Fatal("PPIO provider not found")
@@ -295,10 +321,8 @@ func TestPPIOProviderConfigLoadsIntoProviderManager(t *testing.T) {
}
func TestSiliconFlowProviderConfigLoadsLatestProModels(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "siliconflow.json"), readProviderConfig(t, "siliconflow.json"), 0o600); err != nil {
t.Fatalf("write siliconflow config: %v", err)
}
dir, restore := setupProviderTestDir(t, "siliconflow.json")
defer restore()
err := InitProviderManager(dir)
if err != nil {
@@ -306,7 +330,6 @@ func TestSiliconFlowProviderConfigLoadsLatestProModels(t *testing.T) {
}
pm := GetProviderManager()
provider := pm.FindProvider("SiliconFlow")
if provider == nil {
t.Fatal("SiliconFlow provider not found")

View File

@@ -401,8 +401,14 @@ func (m *MoonshotModel) ListModels(apiConfig *APIConfig) ([]ListModelResponse, e
if err = json.Unmarshal(body, &modelList); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return ParseListModel(modelList), nil
if modelList.Models == nil {
return nil, fmt.Errorf("invalid models list format")
}
models := ParseListModel(modelList)
if len(models) == 0 {
return nil, fmt.Errorf("invalid models list format")
}
return models, nil
}
func (m *MoonshotModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) {

View File

@@ -458,15 +458,33 @@ func (t *TokenHubModel) ListModels(apiConfig *APIConfig) ([]ListModelResponse, e
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse response
// Parse response
var modelList ModelList
if err = json.Unmarshal(body, &modelList); err != nil {
// Parse response. Tokenhub returns `data` as a heterogeneous array
// where some items omit `id` or are not objects at all. Decode into
// a generic envelope so a single bad row cannot fail the whole list,
// then keep only the entries that carry a string `id`.
var envelope struct {
Data interface{} `json:"data"`
}
if err = json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if modelList.Models == nil {
rawItems, ok := envelope.Data.([]interface{})
if !ok {
return nil, fmt.Errorf("invalid models list format")
}
modelList := ModelList{Models: make([]DSModel, 0, len(rawItems))}
for _, raw := range rawItems {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
id, ok := item["id"].(string)
if !ok || strings.TrimSpace(id) == "" {
continue
}
ownedBy, _ := item["owned_by"].(string)
modelList.Models = append(modelList.Models, DSModel{ID: id, OwnedBy: ownedBy})
}
return ParseListModel(modelList), nil
}

View File

@@ -297,7 +297,7 @@ func (x *XiaomiModel) ChatStreamlyWithSender(modelName string, messages []Messag
}
// SSE parsing: read line by line
if _, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error {
if _, err := ParseSSEStreamTolerant[map[string]interface{}](resp.Body, func(event map[string]interface{}) error {
choices, ok := event["choices"].([]interface{})
if !ok || len(choices) == 0 {
return nil
@@ -574,7 +574,7 @@ func decodeXiaomiASRResponse(body []byte) (*ASRResponse, error) {
}
func readXiaomiASRStream(body io.Reader, sender func(*string, *string) error) error {
if _, err := ParseSSEStream[xiaomiChatCompletionChunk](body, func(chunk xiaomiChatCompletionChunk) error {
if _, err := ParseSSEStreamTolerant[xiaomiChatCompletionChunk](body, func(chunk xiaomiChatCompletionChunk) error {
if len(chunk.Choices) == 0 {
return nil
}
@@ -771,7 +771,7 @@ func decodeXiaomiTTSResponse(body []byte) (*TTSResponse, error) {
}
func readXiaomiTTSStream(body io.Reader, sender func(*string, *string) error) error {
if _, err := ParseSSEStream[xiaomiChatCompletionChunk](body, func(chunk xiaomiChatCompletionChunk) error {
if _, err := ParseSSEStreamTolerant[xiaomiChatCompletionChunk](body, func(chunk xiaomiChatCompletionChunk) error {
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta.Audio == nil || chunk.Choices[0].Delta.Audio.Data == "" {
return nil
}