Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions providers/iflow/iflow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Package iflow provides a fantasy.Provider for iFlow API.
package iflow

import (
"bytes"
"encoding/json"
"io"
"maps"
"net/http"

"charm.land/fantasy"
"charm.land/fantasy/providers/openaicompat"
)

const (
// Name is the provider type name for iFlow.
Name = "iflow"
)

type options struct {
baseURL string
apiKey string
headers map[string]string
httpClient *http.Client
}

// Option configures the iFlow provider.
type Option = func(*options)

type iflowTransport struct {
base http.RoundTripper
}

func (t *iflowTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Body == nil || req.Body == http.NoBody || req.Method != http.MethodPost {
return t.base.RoundTrip(req)
}

// iFlow doesn't like max_tokens in the payload
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
_ = req.Body.Close()

var payload map[string]any
if err := json.Unmarshal(body, &payload); err == nil {
// Some providers/models fail if max_tokens is present
delete(payload, "max_tokens")
delete(payload, "max_token")
body, _ = json.Marshal(payload)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iflowTransport silently ignores JSON marshal errors on line 51. If marshaling fails after modifying the payload, the request proceeds with the original body that still contains max_tokens/max_token, defeating the purpose of the transport. Either handle the marshal error properly or ensure the modified payload is used.

Suggested change
body, _ = json.Marshal(payload)
body, err = json.Marshal(payload)
if err != nil {
return nil, err
}

Copilot uses AI. Check for mistakes.
}

req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
return t.base.RoundTrip(req)
Comment on lines +40 to +56
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request body is closed on line 44 but not properly restored if the unmarshal succeeds but marshal fails. In the error path where json.Unmarshal fails (line 47), the original body is lost because it was already read and closed. If Unmarshal fails, the request should use the original body. Consider creating the new body only after both unmarshal and marshal succeed, or restore the original body in the error case.

Copilot uses AI. Check for mistakes.
}

// New creates a new iFlow provider.
// iFlow is based on OpenAI-compatible API but requires special User-Agent header.
func New(opts ...Option) (fantasy.Provider, error) {
o := options{
baseURL: "https://apis.iflow.cn/v1",
headers: make(map[string]string),
}
for _, opt := range opts {
opt(&o)
}

// iFlow requires "iFlow-Cli" User-Agent for premium models
o.headers["User-Agent"] = "iFlow-Cli"
Comment on lines +70 to +71
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The User-Agent header is unconditionally overwritten on line 71, even if the user provided a custom User-Agent via WithHeaders. This prevents users from customizing the User-Agent if needed. Consider only setting the default User-Agent if one isn't already provided, or document that the User-Agent is always forced to "iFlow-Cli".

Suggested change
// iFlow requires "iFlow-Cli" User-Agent for premium models
o.headers["User-Agent"] = "iFlow-Cli"
// iFlow requires "iFlow-Cli" User-Agent for premium models; allow override if explicitly set
if _, ok := o.headers["User-Agent"]; !ok {
o.headers["User-Agent"] = "iFlow-Cli"
}

Copilot uses AI. Check for mistakes.

// Build OpenAI-compatible provider with iFlow-specific configuration
openaiOpts := []openaicompat.Option{
openaicompat.WithBaseURL(o.baseURL),
openaicompat.WithAPIKey(o.apiKey),
}

if len(o.headers) > 0 {
openaiOpts = append(openaiOpts, openaicompat.WithHeaders(o.headers))
}

httpClient := o.httpClient
if httpClient == nil {
httpClient = &http.Client{}
}
baseTransport := httpClient.Transport
if baseTransport == nil {
baseTransport = http.DefaultTransport
}
httpClient = &http.Client{
Transport: &iflowTransport{base: baseTransport},
Timeout: httpClient.Timeout,
Comment on lines +91 to +93
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code creates a new http.Client on line 91 but only copies the Timeout field from the original client. If the user provides a custom HTTP client with other important fields set (e.g., CheckRedirect, Jar, etc.), those fields are silently dropped. Consider copying all fields from the original client or modifying the Transport in-place if possible.

Suggested change
httpClient = &http.Client{
Transport: &iflowTransport{base: baseTransport},
Timeout: httpClient.Timeout,
origClient := httpClient
httpClient = &http.Client{
Transport: &iflowTransport{base: baseTransport},
CheckRedirect: origClient.CheckRedirect,
Jar: origClient.Jar,
Timeout: origClient.Timeout,

Copilot uses AI. Check for mistakes.
}
openaiOpts = append(openaiOpts, openaicompat.WithHTTPClient(httpClient))

return openaicompat.New(openaiOpts...)
}

// WithBaseURL sets the base URL.
func WithBaseURL(url string) Option { return func(o *options) { o.baseURL = url } }

// WithAPIKey sets the API key.
func WithAPIKey(key string) Option { return func(o *options) { o.apiKey = key } }

// WithHeaders sets custom headers.
func WithHeaders(headers map[string]string) Option {
return func(o *options) {
maps.Copy(o.headers, headers)
}
}

// WithHTTPClient sets a custom HTTP client.
func WithHTTPClient(client *http.Client) Option {
return func(o *options) { o.httpClient = client }
}
136 changes: 136 additions & 0 deletions providers/iflow/iflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package iflow

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNew(t *testing.T) {
tests := []struct {
name string
opts []Option
wantErr bool
}{
{
name: "default options",
opts: []Option{},
wantErr: false,
},
{
name: "with custom base URL",
opts: []Option{
WithBaseURL("https://custom.iflow.com/v1"),
},
wantErr: false,
},
{
name: "with API key",
opts: []Option{
WithAPIKey("test-api-key"),
},
wantErr: false,
},
{
name: "with headers",
opts: []Option{
WithHeaders(map[string]string{
"X-Custom-Header": "value",
}),
},
wantErr: false,
},
{
name: "with HTTP client",
opts: []Option{
WithHTTPClient(&http.Client{}),
},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := New(tt.opts...)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if provider == nil {
t.Error("New() returned nil provider")
}
}
})
}
}

func TestName(t *testing.T) {
if Name != "iflow" {
t.Errorf("Expected Name to be 'iflow', got '%s'", Name)
}
}

func TestIFlowTransport(t *testing.T) {
// Create a mock server to receive the request
var capturedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &capturedBody); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello"}}]}`))
}))
defer server.Close()

// Create the transport
transport := &iflowTransport{
base: http.DefaultTransport,
}

// Create a request with max_tokens and max_token
payload := map[string]any{
"model": "test-model",
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
"max_tokens": 100,
"max_token": 100,
}
body, err := json.Marshal(payload)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodPost, server.URL, bytes.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

// Send the request through the transport
client := &http.Client{Transport: transport}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

// Verify that max_tokens and max_token were removed
assert.NotContains(t, capturedBody, "max_tokens")
assert.NotContains(t, capturedBody, "max_token")
assert.Equal(t, "test-model", capturedBody["model"])
}

func TestProviderImplementsInterface(t *testing.T) {
provider, err := New()
if err != nil {
t.Fatalf("Failed to create provider: %v", err)
}

// Verify it implements the Provider interface
var _ fantasy.Provider = provider
}
2 changes: 1 addition & 1 deletion providers/openai/language_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
}
} else {
var err error
if toolCallDelta.Type != "function" {
if toolCallDelta.Type != "" && toolCallDelta.Type != "function" {
err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'function' type."}
}
if toolCallDelta.ID == "" {
Expand Down
73 changes: 73 additions & 0 deletions providers/openai/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2355,6 +2355,79 @@ func TestDoStream(t *testing.T) {
require.Equal(t, `{"value":"Sparkle Day"}`, fullInput)
})

t.Run("should stream tool deltas without type field (devstral-style)", func(t *testing.T) {
t.Parallel()

server := newStreamingMockServer()
defer server.close()

// Simulate devstral-style response: tool_calls without type field, finish_reason in same chunk
chunks := []string{
`data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711357598,"model":"devstral-2512","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1711357598,"model":"devstral-2512","choices":[{"index":0,"delta":{"tool_calls":[{"id":"tg7UYnLaz","function":{"name":"grep","arguments":"{\"pattern\": \"devstral\", \"literal_text\": true}"},"index":0}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":14797,"total_tokens":14815,"completion_tokens":18}}` + "\n\n",
"data: [DONE]\n\n",
}
server.chunks = chunks

provider, err := New(
WithAPIKey("test-api-key"),
WithBaseURL(server.server.URL),
)
require.NoError(t, err)
model, _ := provider.LanguageModel(t.Context(), "devstral-2512")

stream, err := model.Stream(context.Background(), fantasy.Call{
Prompt: testPrompt,
Tools: []fantasy.Tool{
fantasy.FunctionTool{
Name: "grep",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
},
"literal_text": map[string]any{
"type": "boolean",
},
},
"required": []string{"pattern"},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#",
},
},
},
})

require.NoError(t, err)

parts, err := collectStreamParts(stream)
require.NoError(t, err)

// Find tool-related parts
var toolCall *fantasy.StreamPart
var finishPart *fantasy.StreamPart

for i := range parts {
if parts[i].Type == fantasy.StreamPartTypeToolCall {
toolCall = &parts[i]
}
if parts[i].Type == fantasy.StreamPartTypeFinish {
finishPart = &parts[i]
}
}

// Verify tool call was processed
require.NotNil(t, toolCall, "tool call should be present")
require.Equal(t, "tg7UYnLaz", toolCall.ID)
require.Equal(t, "grep", toolCall.ToolCallName)
require.Equal(t, `{"pattern": "devstral", "literal_text": true}`, toolCall.ToolCallInput)

// Verify finish reason was set correctly
require.NotNil(t, finishPart, "finish part should be present")
require.Equal(t, fantasy.FinishReasonToolCalls, finishPart.FinishReason)
})

t.Run("should stream annotations/citations", func(t *testing.T) {
t.Parallel()

Expand Down
Loading