From cdbd2250f613b58bd7cbcb7c4dbcfaa6f8fa4cf0 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 02:37:55 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20AIHubMix's=20GPT=20Image=202=20and?= =?UTF-8?q?=202.5=20can=20be=20picked=20to=20draw=20(=E6=88=91=E8=AE=B0?= =?UTF-8?q?=E5=BE=97AI=20Hub=20Mix=E6=98=AF=E6=9C=89GPT=20Image=202?= =?UTF-8?q?=E4=BB=A5=E5=8F=8AGPT=20Image=202.5=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=EF=BC=8C=E4=B8=BA=E4=BB=80=E4=B9=88=E9=80=89=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E5=91=A2=EF=BC=9F)=20=E2=80=94=20AIHubMix's=20/v1/models,=20as?= =?UTF-8?q?ked=20with=20a=20key,=20lists=20only=20its=20Gemini=20image=20m?= =?UTF-8?q?odels,=20though=20the=20same=20key=20draws=20with=20gpt-image-1?= =?UTF-8?q?/2/2.5=20and=20the=20rest;=20its=20public=20catalog=20(aihubmix?= =?UTF-8?q?.com/api/v1/models)=20lists=20them=20all.=20Now=20when=20an=20A?= =?UTF-8?q?IHubMix=20provider's=20list=20is=20fetched,=20the=20image=20mod?= =?UTF-8?q?els=20of=20that=20catalog=20join=20it=20as=20drawing=20(Setting?= =?UTF-8?q?s=20=E2=86=92=20Images,=20never=20offered=20to=20agents=20to=20?= =?UTF-8?q?chat=20with);=20Ideogram's=20V=5F2,=20DESCRIBE=20and=20UPSCALE?= =?UTF-8?q?=20are=20left=20out.=20Checked=20in=20a=20sandbox=20HOME=20agai?= =?UTF-8?q?nst=20the=20real=20AIHubMix:=20after=20a=20refresh=20the=20pick?= =?UTF-8?q?er=20has=20aihubmix/gpt-image-2,=20gpt-image-2.5-flare=20and=20?= =?UTF-8?q?2.5-sunburst=20among=2046=20of=20its=20image=20models,=20and=20?= =?UTF-8?q?gpt-image-2=20and=20gpt-image-2.5-flare=20each=20drew=20a=20102?= =?UTF-8?q?4=C2=B2=20PNG=20through=20the=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/catalog/public.go | 96 ++++++++++++++++++++++++ internal/provider/models.go | 5 +- internal/provider/public_drawers_test.go | 53 +++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 internal/catalog/public.go create mode 100644 internal/provider/public_drawers_test.go diff --git a/internal/catalog/public.go b/internal/catalog/public.go new file mode 100644 index 00000000..841662fa --- /dev/null +++ b/internal/catalog/public.go @@ -0,0 +1,96 @@ +package catalog + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +// PublicCatalogs are vendors whose /models, asked with a key, leaves out +// the image models the key can use: AIHubMix lists only its Gemini image +// models there, while gpt-image-2 and the rest draw with the same key. Its +// public catalog, by the host of the vendor's base URL, has them. +var PublicCatalogs = map[string]string{ + "aihubmix.com": "https://aihubmix.com/api/v1/models", +} + +// PublicDrawers are the image models the public catalog for base's host +// lists, marked as drawing; nil when there is none or it can't be read. +func PublicDrawers(ctx context.Context, base string) []Model { + u, err := url.Parse(strings.TrimSpace(base)) + if err != nil { + return nil + } + at, ok := PublicCatalogs[strings.TrimPrefix(u.Hostname(), "www.")] + if !ok { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, at, nil) + if err != nil { + return nil + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "magpie") + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil + } + b, _ := io.ReadAll(io.LimitReader(res.Body, 16<<20)) + var list struct { + Data []json.RawMessage `json:"data"` + } + if json.Unmarshal(b, &list) != nil { + return nil + } + var out []Model + for _, raw := range list.Data { + var m struct { + ID string `json:"model_id"` + Name string `json:"model_name"` + Types string `json:"types"` + Out string `json:"output_modalities"` + In string `json:"input_modalities"` + Release string `json:"release_date"` + } + if json.Unmarshal(raw, &m) != nil || m.ID == "" { + continue + } + // what makes images and is asked the way magpie draws: Ideogram's + // V_2, DESCRIBE and UPSCALE are the vendor's own tasks + if !strings.Contains(m.Types, "image_generation") && !strings.Contains(m.Out, "image") || !DrawsID(m.ID) { + continue + } + d := Model{ID: m.ID, Name: m.Name, Draws: true, Released: m.Release} + if d.Name == "" { + d.Name = m.ID + } + if slices.Contains(strings.Split(m.In, ","), "image") { + d.Images, d.ImageInput = true, imageInputOf(true) + } + out = append(out, d) + } + return out +} + +// WithDrawers adds the drawers not already in ms. +func WithDrawers(ms, drawers []Model) []Model { + for _, d := range drawers { + if !slices.ContainsFunc(ms, func(m Model) bool { return m.ID == d.ID }) { + ms = append(ms, d) + } + } + return ms +} + +func imageInputOf(b bool) *bool { return &b } diff --git a/internal/provider/models.go b/internal/provider/models.go index 37d3c0bd..9e14891a 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -117,7 +117,7 @@ func (p Provider) fetchOne(ctx context.Context) ([]catalog.Model, string, error) if err != nil { return nil, u, err } - return p.planModels(ms), u, nil + return catalog.WithDrawers(p.planModels(ms), catalog.PublicDrawers(ctx, u)), u, nil } var errs []string for _, proto := range p.Speaks() { @@ -127,7 +127,8 @@ func (p Provider) fetchOne(ctx context.Context) ([]catalog.Model, string, error) if proto != Anthropic { base = p.fixV1(base, at) } - return p.planModels(ms), base, nil + // the image models its list leaves out (AIHubMix's gpt-image-2) + return catalog.WithDrawers(p.planModels(ms), catalog.PublicDrawers(ctx, base)), base, nil } // Chat and Responses at one base say the same thing if !slices.Contains(errs, err.Error()) { diff --git a/internal/provider/public_drawers_test.go b/internal/provider/public_drawers_test.go new file mode 100644 index 00000000..ff7997db --- /dev/null +++ b/internal/provider/public_drawers_test.go @@ -0,0 +1,53 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// AIHubMix's /models leaves out gpt-image-2, which its key draws with; its +// public catalog has it, and the list kept for the provider gets it. +func TestPublicCatalogsImageModelsJoinTheList(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/models" { + if r.Header.Get("Authorization") != "" { + t.Errorf("the public catalog was sent the key") + } + w.Write([]byte(`{"success":true,"data":[ + {"model_id":"gpt-image-2","model_name":"GPT Image 2","types":"image_generation","input_modalities":"text,image","output_modalities":"image","release_date":"2026-04-21","pricing":{"input":5,"output":30}}, + {"model_id":"gemini-2.5-flash-image","model_name":"Gemini","types":"image_generation","output_modalities":"image","release_date":null}, + {"model_id":"V_2","model_name":"Ideogram","types":"image_generation","output_modalities":"image"}, + {"model_id":"gpt-5","model_name":"GPT-5","types":"llm","output_modalities":"text"}]}`)) + return + } + w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":"gemini-2.5-flash-image"}]}`)) + })) + defer server.Close() + old := catalog.PublicCatalogs + catalog.PublicCatalogs = map[string]string{"127.0.0.1": server.URL + "/api/v1/models"} + t.Cleanup(func() { catalog.PublicCatalogs = old }) + + p := Provider{ID: "hub", Chat: server.URL + "/v1", Key: "k"} + chat, err := p.Fetch(context.Background()) + if err != nil || len(chat) != 1 || chat[0].ID != "gpt-5" { + t.Fatalf("chat models %+v, %v", chat, err) + } + var ids []string + for _, m := range catalog.LiveDrawers("hub") { + ids = append(ids, m.ID) + if m.ID == "gpt-image-2" && (m.Name != "GPT Image 2" || !m.Images || m.Price != nil) { + t.Errorf("gpt-image-2 kept as %+v", m) + } + } + slices.Sort(ids) + if !slices.Equal(ids, []string{"gemini-2.5-flash-image", "gpt-image-2"}) { + t.Fatalf("drawers %v", ids) + } +}