-
Notifications
You must be signed in to change notification settings - Fork 58
feat: add Xiaomi & iFlow providers implementation and tests #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
adea7ee
0d011ed
c7d6e6a
5af021d
76edb25
df0c8ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| req.Body = io.NopCloser(bytes.NewReader(body)) | ||||||||||||||||||||
| req.ContentLength = int64(len(body)) | ||||||||||||||||||||
| return t.base.RoundTrip(req) | ||||||||||||||||||||
|
Comment on lines
+40
to
+56
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 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
|
||||||||||||||||||||
| // 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
AI
Jan 5, 2026
There was a problem hiding this comment.
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.
| 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, |
| 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 | ||
| } |
There was a problem hiding this comment.
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.