From 068fdda7ad5bdc15a096fea5a28336bcf2d48688 Mon Sep 17 00:00:00 2001 From: fenil modi Date: Fri, 2 Oct 2026 03:42:33 +0000 Subject: [PATCH 1/3] connect: ai& is a provider of its own, and one key reaches four labs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai& (api.aiand.com) is a Japan-hosted, OpenAI- and Anthropic-compatible inference service selling one prepaid credit per token. Its list reaches models from several labs at once, so one key covers what would otherwise take four accounts. The PROVIDER is one row in modelsource.Vendored(). Everything else the person touches is already derived from that catalog: the connect panel, the providers group, the picker group, the Providers tab and `codeaf connect aiand`. Config.Direct already switches off the router's lane sheet, generation-receipt fetch, price ceiling and routing vocabulary for a vendor reached directly, so a direct vendor stays a base-URL swap. internal/session needed no change either — clientdoor.go keys its adapter pool on the resolved connection and strips the service segment from the id it sends, which is why ai&'s own two-segment ids already reach it whole. WHAT MAKES THIS ROW NAME SIX VENDORS RATHER THAN ONE IS THAT AI& IS NOT ONE LAB. Every other direct connection here is one vendor with one model family, which is why the existing rows need no vendor list: their own Written prefix already names their only family. ai& reaches GLM, DeepSeek, Qwen and Kimi at once, so the crew is given the CATALOG vendor words it actually serves — crewVendors["aiand"] names deepseek, z-ai, moonshotai, qwen, openai and google — and one key can carry a planner and a checker sitting on DIFFERENT labs in the same task. That is the shape the provider exists for, and it is proved on the SEATS rather than on the catalog, because a row that quietly collapsed to one family would still pass every send assertion. `google` is in that list even though no other connection carries it, because what the crew needs is a CATALOG vendor and the catalog has a gemma family; the seventh organisation on the listing, motif-technologies, is in no catalog row, so the crew cannot weigh Motif 3 however good its own numbers are. TWO FACTS THE CATALOG ALONE DOES NOT GIVE, and both are about that send. ai& is the first connection here that is ITSELF A ROUTER: its ids already name the lab that built the model (`zai-org/glm-5.3`, `deepseek-ai/deepseek-v4.1- flash`), so a send is the provider segment over the vendor's OWN id rather than a bare name. crewVendorWire spells the catalog's vendor the way ai& spells it. This is not a tidiness fix. Asked on 2026-10-02 for the bare names that table exists to avoid sending — `deepseek-v4-flash`, `glm-5.3-flash`, `kimi-k3` — api.aiand.com answered 404 `model_not_found` for every one, while `deepseek-ai/deepseek-v4-flash` and `zai-org/glm-5.3-flash` answered 200. So a send without the rename is a call that fails, not a spelling somebody might prefer. A vendor the two spell the same way keeps the catalog's own segment, so `moonshotai/kimi-k3` is sent as `aiand/moonshotai/kimi-k3` rather than being renamed to some other organisation's name. Note this is NOT crewroute's vendorOrgs table read backwards: that one maps `moonshot` to the catalog's `moonshotai`, and folding the two together would rename a send that works into one that 404s. THE PREFERENCE MOVED, and the vendor's own manifest is why. https://api.aiand.com/v1/api.json answers 200 without a key and publishes cost, limits, modalities and capabilities per model. It says the flagship, `zai-org/glm-5.3`, is `attachment: false` with `input: [text]` — so a person who dropped a screenshot into their first ai& conversation was refused by the model codeaf had just seated them on. The preference is now `zai-org/glm-5.3-flash`: same family, same million tokens (1048550 against 1048576), `input: [text, image, video]`, and $0.15/$0.50 against $1.00/$4.00. It is the cheapest of the four rows that take attachments and hold 1M, which is the rule Source already states — the vendor's best model, not simply its flagship. DELIBERATELY NOT COPIED FROM ALIBABA'S ROW. DashScope declares two regions and two billing doors because it genuinely runs an international and a China host and sells a coding plan beside metered traffic. ai& is Japan-only on one endpoint and sells one prepaid credit, so there is no region to choose and no second billing product to tell apart — and a test already holds that law: a second door is legal only with wire evidence distinguishing the billing products, which is why MiniMax declares none either. Offering a choice that does not exist is worse than offering none. The one-door claim says why in the row for the same reason: nothing on the wire separates a plan from metered credit. A 402 insufficient_credits is already read correctly by paymentrefusal's generic 402 rule. ai& collides with no model author, so it keeps the name `aiand` and never becomes `aiand-direct`. DELIBERATELY NOT FIXED HERE, AND IT IS NOT A BUG IN THIS ROW. ai& publishes the effort words per model (`reasoning_efforts` on every row of /v1/models) and only `high` is universal across the thirteen: kimi-k2.7-code takes `high` alone, qwen3.8-27b takes none/low/medium/xhigh and so NOT `high`, and glm-5.3 takes low/high/max and so neither `none` nor `medium`. The listing parse keeps only the `id`, and the adapter's ReasoningProfile seam is fed by the ROUTER's catalog, so a direct provider's profile reads unknown and the requested word goes out unclamped. Teaching a direct provider to publish its own reasoning profile means changing internal/catalog, internal/config and internal/provider together, and it would move effort handling for every direct connection rather than this one. It wants its own change and a live key; the change entry records the measurements so they are not rediscovered. The rest of the diff is what the row forces. The vendored-row test pins the table's order and its Ollama-is-the-only-optional-key law, so both move and the Ollama lookup stops being an index — inserting a row above it must not have to be re-counted. The three test helpers that clear provider key variables gain AIAND_API_KEY, because connect/key.go resolves a connection straight from the environment and a stray variable would otherwise leak a CONNECTED ai& into those panels. A listing test holds that a model the catalog cannot weigh still reaches the picker with its vendor segment whole, which is the other half of the motif story and the wrong fix twice over. The manual gains the provider in the two places that named the group and counted it, where leaving the old list would have made the page false; the crew page needed no edit, because it already says every model a connected provider serves is a candidate, and that sentence is now true of ai& rather than needing one written for it. Verified live against the API on 2026-10-02: the listing, a streamed reply with its usage frame, tool calling, max_tokens, stream_options, the refusal of a bare model name, and the manifest every cost and modality figure above is read from. Also observed: X-Cost is advertised in access-control-expose-headers and never sent, so a direct provider's cost cannot be read off a response. --- .../unreleased/1736-aiand-native-provider.md | 120 +++++++++++++ internal/config/crew.go | 74 +++++++- internal/config/crew_test.go | 164 +++++++++++++++++- internal/config/sources_test.go | 42 +++++ internal/manual/chat/commands.md | 4 +- internal/manual/chat/services.md | 4 +- internal/modelsource/modelsource.go | 34 ++++ internal/modelsource/modelsource_test.go | 34 +++- internal/tui3/crewpanel_test.go | 2 +- internal/tui3/modelservices_test.go | 2 +- 10 files changed, 468 insertions(+), 12 deletions(-) create mode 100644 docs/changes/unreleased/1736-aiand-native-provider.md diff --git a/docs/changes/unreleased/1736-aiand-native-provider.md b/docs/changes/unreleased/1736-aiand-native-provider.md new file mode 100644 index 0000000000..acde1cd200 --- /dev/null +++ b/docs/changes/unreleased/1736-aiand-native-provider.md @@ -0,0 +1,120 @@ +--- +kind: added +title: ai& is offered as a provider of its own, asked for by key and nothing else +pr: 1736 +surface: [chat, engine] +invalidates: + - >- + ai& was reachable only by hand, as a **Custom OpenAI-compatible API** row with + `https://api.aiand.com/v1` typed into it, a name typed beside it and a key + pasted in. It now has its own row in `/connect`, its own name in + `codeaf connect aiand` and its own row on the Providers tab in `/settings`. + - >- + `/connect` said it held "the six built-in model providers" while the group + already carried seven named rows. It now names the eight and spells them out, + so the count can be checked against the list instead of remembered. + - >- + A provider that served more than one lab had no way to say so. Every direct + connection was treated as one family, because one lab's own API takes one + family. ai& is reached through six catalog vendors from the one key, and the + crew can now put a planner and a checker on different labs through it. + - >- + A connection is seated on its vendor's flagship, on the assumption that the + best model is the one to land a person on. On ai& that model takes text + alone, so the first attachment a person tried was refused by the model + codeaf had chosen for them. The preference is now the best model that also + takes a file. +--- + +One prepaid credit spends any model in ai&'s list, and that list reaches several +labs at once, so a single key covers models a person would otherwise open four +different accounts for. + +This change is one row in `modelsource.Vendored()` and nothing else. Every other +surface — the connect panel, the providers group, the picker group, the Providers +tab and `codeaf connect aiand` — is derived from that catalog, and +`Config.Direct` already switches off the router's lane sheet, receipt fetch, price +ceiling and routing vocabulary for a vendor reached directly, so a direct vendor +stays a base-URL swap. `internal/session` needed no change either: +`clientdoor.go` keys its adapter pool on the resolved connection and strips the +service segment from the id it sends, which is why ai&'s own two-segment ids +(`zai-org/glm-5.3`, `deepseek-ai/deepseek-v4.1-flash`) already reach it whole. + +ai& lists its models — `GET /v1/models` answers — so the connect line carries the +count that came back, for example `aiand is connected · 13 models`, and `/model` +fills its group from that list rather than asking for a typed id. The list is +organised by organisation and moves as the vendor adds and drops models, so the +number in the line is that day's answer and not something codeaf remembers. + +The row's preference was CHANGED by what that list says. It was the flagship, +`zai-org/glm-5.3`, and the vendor's own manifest +(`https://api.aiand.com/v1/api.json`, read 2026-10-02) answers 200 for that model +with `attachment: false` and `input: [text]` — so a person who dropped a +screenshot into their first ai& conversation was refused by the model codeaf had +just seated them on. It is now `zai-org/glm-5.3-flash`: the same family, the same +million tokens, `input: [text, image, video]`, and $0.15/$0.50 against the +flagship's $1.00/$4.00. The rule the row follows is the one `Source` already +states — the vendor's best model, not simply its flagship. + +TWO THINGS THE MANIFEST SAYS THAT THIS CHANGE DOES NOT ACT ON, both recorded so +they are not rediscovered as bugs. + +The effort words are per-model and do not line up with codeaf's ladder. Only +`high` is universal across the thirteen: `moonshotai/kimi-k2.7-code` takes `high` +and nothing else, `qwen/qwen3.8-27b` takes `none,low,medium,xhigh` and so NOT +`high`, and `zai-org/glm-5.3` takes `low,high,max` and so neither `none` nor +`medium`. ai& publishes exactly the field that would settle this per model +(`reasoning_efforts` and `reasoning_effort_default` on every row of +`/v1/models`), but the listing parse keeps only the `id`, and the adapter's +`ReasoningProfile` seam is fed by the ROUTER's catalog — so for a direct provider +the profile reads unknown and the requested word goes out unclamped. Teaching a +direct provider to publish its own reasoning profile is a change to +`internal/catalog`, `internal/config` and `internal/provider` at once, and it +would alter effort handling for every direct connection, not just this one. It +wants its own change and a live key. + +`motif-technologies/motif-3` is the one row of the thirteen that answers +`structured_output: false`. Nothing to do about it here: the crew cannot reach +that vendor at all (below), so it is only ever a model a person picks by hand. + +It has one billing door and codeaf makes no plan claim about it, which is the same +honesty MiniMax's sentences already keep: nothing on the wire separates a +subscription from metered credit, and a money label codeaf cannot check is worse +than no label. A run with nothing left on the credit is answered by ai& with a +`402` and `insufficient_credits` — the balance speaking, not a bad key. + +It collides with no model author, so it keeps the name `aiand` and never becomes +`aiand-direct`; that is the word `codeaf connect` takes and the first segment of +every model id it serves. + +The crew router reaches it too, and that needed a second fact the catalog does not +give away. ai& is the first connection here that is ITSELF a router: its ids +already name the lab that built the model, so a send is the provider segment over +the vendor's OWN id rather than a bare name. `crewVendors` maps a connection to +the CATALOG's vendor words, because that is what the router weighs — and the +catalog says `deepseek/` and `z-ai/` where ai& says `deepseek-ai/` and +`zai-org/`. `crewVendorWire` carries that rename on the send. + +WHAT MAKES AI& A ROW NAMING SIX VENDORS RATHER THAN ONE IS THAT IT IS NOT ONE +LAB. Its listing reaches GLM, DeepSeek, Qwen and Kimi at once, so a single key +buys the same families a person would otherwise open four accounts for. The crew +therefore treats it as one route that spans families, not as one more single-family +vendor: `crewVendors["aiand"]` names `deepseek`, `z-ai`, `moonshotai`, `qwen`, +`openai` and `google`, and one key can carry a planner and a checker sitting on +different labs in the same task. A vendor that served a single family would need +no row at all, because its own `Written` would already be that family. + +It is not a tidiness fix. Asked on 2026-10-02 for the bare names that table exists +to avoid sending — `deepseek-v4-flash`, `glm-5.3-flash`, `kimi-k3` — +api.aiand.com answered 404 `model_not_found` for every one, while +`deepseek-ai/deepseek-v4-flash` and `zai-org/glm-5.3-flash` answered 200. So a +send without the rename is a call that fails, not a spelling somebody might +prefer. + +Six of the seven organisations on that list are vendors the catalog already +carries, and all six are named in the crew row — including `google`, so +`google/gemma-4-31b-it` is reached as `aiand/google/gemma-4-31b-it` and the crew +may pick a Gemma for a seat. The seventh, `motif-technologies`, is a vendor no +catalog row names, so the crew cannot weigh it and never offers Motif 3 however +good its figures are; it stays pickable by hand, which is all a person can do +with a model the router has never heard of. diff --git a/internal/config/crew.go b/internal/config/crew.go index 41fc87d2fe..8626e24795 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -690,11 +690,61 @@ type CrewProvider struct { collides map[string]bool } -// crewVendors maps a direct connection to the catalog vendor prefixes it +// crewVendors maps a direct connection to the CATALOG vendor prefixes it // serves. A connection whose Written is the vendor's own prefix needs no row. +// +// THE PREFIXES ARE THE CATALOG'S, NOT THE CONNECTION'S OWN. The router weighs +// catalog ids ([CrewCatalog]), and [CrewProvider.route] reads the vendor off the +// front of one; the connection's spelling is what the SEND is built from, below. +// Moonshot's catalog says `moonshotai/kimi-k3` and Codex's says `openai/gpt-5.5`, +// which is why those two rows read the way they do. A TABLE OF FACTS: every +// entry is a vendor somebody watched that connection serve. var crewVendors = map[string][]string{ "moonshot": {"moonshotai"}, "codex": {"openai"}, + // OBSERVED 2026-10-02 on a live GET https://api.aiand.com/v1/models with a + // key, and read again off https://api.aiand.com/v1/api.json: thirteen models + // under seven prefixes, which are six catalog vendors. `google` is among them + // even though no OTHER connection carries it, because what this table needs is + // a CATALOG vendor and the catalog has a gemma family, so the crew may seat a + // Gemma through this one key. + // + // THE SEVENTH, motif-technologies, IS NOT IN THIS TABLE AND THAT IS CORRECT. + // No catalog row names the vendor, so the router has no figures to weigh and + // the model can never become a candidate — however good Motif 3's own numbers + // are, and it is a 314B MoE. It still reaches a person: config's listing parse + // keeps every id the endpoint publishes, so `motif-technologies/motif-3` sits + // in the picker and is sent whole. It is also the one row of the thirteen that + // answers `structured_output: false`, which matters to whoever picks it by + // hand. + // + // THE LIST IS ORG-SCOPED AND DYNAMIC: ai& adds and retires orgs, so a vendor + // missing from it is a model this connection no longer serves, not a claim + // that it never could. + "aiand": {"deepseek", "z-ai", "moonshotai", "qwen", "openai", "google"}, +} + +// crewVendorWire spells the catalog's vendor segment the way a connection's OWN +// API spells it, where the two differ. A vendor absent from a connection's row +// keeps the catalog's own spelling — the ordinary case, and the reason moonshot +// and codex need no row here: the catalog already says `moonshotai/` and +// `openai/`. A connection named here is ITSELF A ROUTER: it addresses a model as +// / and never as a bare name, so its send keeps the segment a +// vendor's own API would have dropped. +// +// ai& IS THE FIRST SUCH CONNECTION, AND THE TABLE IS NOT OPTIONAL. Its listing +// names `deepseek-ai/deepseek-v4-flash` and `zai-org/glm-5.3-flash` where the +// catalog says `deepseek/deepseek-v4-flash` and `z-ai/glm-5.3-flash`. +// +// A BARE NAME IS REFUSED, which is what makes the send a correctness question +// rather than a tidiness one. Asked on 2026-10-02 for the bare names this table +// exists to avoid sending — `deepseek-v4-flash`, `glm-5.3-flash`, `kimi-k3` — +// api.aiand.com answered 404 `model_not_found` for every one, while +// `deepseek-ai/deepseek-v4-flash` and `zai-org/glm-5.3-flash` answered 200. So +// the send without this table is not a spelling somebody might prefer; it is a +// call that fails. A TABLE OF FACTS, one spelling each. +var crewVendorWire = map[string]map[string]string{ + "aiand": {"deepseek": "deepseek-ai", "z-ai": "zai-org"}, } // CrewProvidersAt is every provider the crew can route through: each @@ -800,7 +850,27 @@ func (p CrewProvider) route(id string, model crewroute.Model, known bool) (crewr return crewroute.Route{}, false } } - return crewroute.Route{Provider: p.ID, Send: p.Written + "/" + tail, Kind: p.Kind}, true + return crewroute.Route{Provider: p.ID, Send: p.send(vendor, tail), Kind: p.Kind}, true +} + +// send is the id a call to this connection carries, which is not always its +// prefix over the model's name. +// +// THE ORDINARY SEND DROPS THE CATALOG'S VENDOR SEGMENT, because every vendor +// whose own API this table has reached so far takes a BARE model id: the +// catalog's `moonshotai/kimi-k3` goes out as `moonshot/kimi-k3`. A CONNECTION +// THAT IS ITSELF A ROUTER keeps the segment, spelled as its own API spells it +// ([crewVendorWire]) — `aiand/deepseek-ai/deepseek-v4-flash`, which is the id +// that listing named, rather than an `aiand/deepseek-v4-flash` no router of that +// shape has ever heard of. +func (p CrewProvider) send(vendor, tail string) string { + if wire, ok := crewVendorWire[p.ID]; ok { + if spelled, ok := wire[vendor]; ok { + return p.Written + "/" + spelled + "/" + tail + } + return p.Written + "/" + vendor + "/" + tail + } + return p.Written + "/" + tail } // CrewCandidatesAt is what the router may pick from on this profile: every diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 733991454a..712f717a16 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -38,7 +38,8 @@ func rawCrewRow(t *testing.T, dir, key string) string { func crewProfile(t *testing.T) string { t.Helper() for _, name := range []string{APIKeyEnv, "OPENAI_API_KEY", ModelEnv, PlanModelEnv, CheckModelEnv, "CODEAF_BASE_URL", - "DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY", "MINIMAX_API_KEY", "DASHSCOPE_API_KEY"} { + "DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY", "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", + "AIAND_API_KEY"} { t.Setenv(name, "") } dir := t.TempDir() @@ -399,6 +400,167 @@ func TestAPlanRouteIsFreeAndCollidingIdsKeepTheirRouterSpelling(t *testing.T) { } } +// A DIRECT CONNECTION REACHES THE CATALOG VENDORS IT ACTUALLY CARRIES, which is +// what [crewVendors] is for: Moonshot's Written is `moonshot` where the catalog +// writes `moonshotai/`, and Codex's is `codex` where the catalog writes `openai/`, +// so neither would ever be offered a Kimi or a GPT without a row naming the +// vendor the catalog actually uses. +// +// THE SEND IS THE ID THAT CONNECTION'S OWN API KNOWS, and that is not always the +// catalog's id with the segment dropped. ai& is the first connection whose own +// API is ITSELF a router — its 2026-10-02 listing named +// `deepseek-ai/deepseek-v4-flash` and `zai-org/glm-5.3-flash` — so its send +// keeps the segment, spelled as ai& spells it rather than as the catalog does. +// +// AND IT IS A MULTI-FAMILY PROVIDER, which is the whole reason a row naming six +// vendors exists at all. ai& is not one lab with one model line: its listing +// reaches GLM, DeepSeek, Qwen and Kimi at once, so ONE key buys the same +// families a person would otherwise open four accounts for. The two facts are +// kept apart on purpose — a vendor that served only one family would need no row +// naming vendors at all, because its own Written would be that family. +func TestADirectRouterConnectionServesItsCatalogVendorsUnderItsOwnIds(t *testing.T) { + dir := crewProfile(t) + if err := writeProfileValue(dir, keyModelSources, []PersistedSource{ + {ID: "aiand", Written: "aiand", Key: "sk-aiand-crewtest-0123456789ab", Order: 1}, + }); err != nil { + t.Fatal(err) + } + // THE FIXTURE CATALOG IS DELIBERATELY WIDENED HERE AND ONLY HERE. The shared + // rows carry one model per family for three families, which is enough to keep + // the routing tests cheap but cannot show a multi-family provider reaching + // more than one — and a claim that one key spans four labs deserves the four + // labs in the fixture rather than an inference from three. + widenCrewCatalog(t, + catalog.Model{ID: "qwen/qwen3.8-flash", OpenWeights: true, PromptPrice: 2e-7, CompletionPrice: 6e-7, + IntelligenceIndex: 40.1, CodingIndex: 70.2, AgenticIndex: 49, ArenaElo: 1330, + ContextLength: 1310720, Parameters: []string{"tools"}}, + catalog.Model{ID: "openai/gpt-oss-120b", OpenWeights: true, PromptPrice: 1e-7, CompletionPrice: 4e-7, + IntelligenceIndex: 33, CodingIndex: 55, AgenticIndex: 30, ArenaElo: 1200, + ContextLength: 131072, Parameters: []string{"tools"}}, + catalog.Model{ID: "google/gemma-4-31b-it", OpenWeights: true, PromptPrice: 2e-7, CompletionPrice: 5e-7, + IntelligenceIndex: 36, CodingIndex: 58, AgenticIndex: 31, ArenaElo: 1240, + ContextLength: 262144, Parameters: []string{"tools"}}, + // AND THE ONE THAT IS NOT. motif-technologies is on ai&'s listing and in + // no catalog row, so it can never become a candidate the crew weighs — + // which is the whole difference between a vendor ai& happens to serve and + // one the router knows how to price. + catalog.Model{ID: "motif-technologies/motif-3", OpenWeights: true, PromptPrice: 5e-7, CompletionPrice: 2e-6, + IntelligenceIndex: 45, CodingIndex: 68, AgenticIndex: 44, ArenaElo: 1300, + ContextLength: 262144, Parameters: []string{"tools"}}, + ) + routes := map[string]string{} + families := map[string]bool{} + for _, c := range CrewCandidatesAt(dir) { + for _, r := range c.Routes { + if r.Provider == "aiand" { + routes[c.Model.ID] = r.Send + if vendor, _, ok := strings.Cut(c.Model.ID, "/"); ok { + families[vendor] = true + } + } + } + } + want := map[string]string{ + // Both renames come from the listing the row's comment cites. + "deepseek/deepseek-v4-flash": "aiand/deepseek-ai/deepseek-v4-flash", + "z-ai/glm-5.3-flash": "aiand/zai-org/glm-5.3-flash", + // And a vendor the catalog and ai& already spell the same way keeps the + // catalog's segment rather than losing it. + "moonshotai/kimi-k3": "aiand/moonshotai/kimi-k3", + "qwen/qwen3.8-flash": "aiand/qwen/qwen3.8-flash", + "openai/gpt-oss-120b": "aiand/openai/gpt-oss-120b", + // A vendor no OTHER connected provider carries is still reached, because + // what the crew needs is a CATALOG vendor and google is one. This is the + // half of the listing that is easy to get backwards. + "google/gemma-4-31b-it": "aiand/google/gemma-4-31b-it", + } + for model, send := range want { + if routes[model] != send { + t.Errorf("%s through ai& sends %q, want %q", model, routes[model], send) + } + } + // THE MULTI-FAMILY CLAIM, counted rather than read off the sends above: one + // connection, four labs. A row that quietly collapsed to a single family would + // still pass every send assertion while failing this. + for _, family := range []string{"deepseek", "z-ai", "moonshotai", "qwen"} { + if !families[family] { + t.Errorf("ai& reaches no %s model, want a family alongside the other three", family) + } + } + // A vendor it does not carry is still not offered through it, however good + // the catalog's figures for it are. + if _, offered := routes["anthropic/claude-opus-5"]; offered { + t.Error("ai& offers a model from a vendor it does not serve") + } + // AND a model the CATALOG does not carry is not offered through it either, + // however good ai&'s own figures are. Motif 3 is on the listing and in the + // crew row's absence is the whole story: no catalog row, no candidate. + if _, offered := routes["motif-technologies/motif-3"]; offered { + t.Error("ai& offers a model no catalog row names, so the crew cannot weigh it") + } +} + +// ONE KEY, TWO FAMILIES, TWO SEATS. This is the claim the row above cannot make +// on its own: that a multi-family provider is not merely reachable but USABLE as +// one route, so a single connection can carry a planner and a checker that sit on +// different labs in the same task. A crew that treated ai& as one family would +// have to put both seats on the same model, and the point of the provider is that +// it does not have to. +func TestOneMultiFamilyKeyCarriesSeatsOnDifferentLabs(t *testing.T) { + dir := crewProfile(t) + if err := writeProfileValue(dir, keyModelSources, []PersistedSource{ + {ID: "aiand", Written: "aiand", Key: "sk-aiand-crewtest-0123456789ab", Order: 1}, + }); err != nil { + t.Fatal(err) + } + widenCrewCatalog(t, catalog.Model{ID: "qwen/qwen3.8-flash", OpenWeights: true, PromptPrice: 2e-7, CompletionPrice: 6e-7, + IntelligenceIndex: 40.1, CodingIndex: 70.2, AgenticIndex: 49, ArenaElo: 1330, + ContextLength: 1310720, Parameters: []string{"tools"}}) + decision, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + if err != nil { + t.Fatal(err) + } + seats := map[crewroute.Seat]crewroute.Pick{} + for _, seat := range crewroute.Seats { + seats[seat] = decision.Seat(seat) + } + // Every seat is answered, and every answer is answered by the one key. + labelled := map[string][]string{} + for _, seat := range crewroute.Seats { + pick := seats[seat] + if pick.Model == "" { + t.Fatalf("the %s seat got no model", seat) + } + if pick.Provider != "aiand" { + t.Errorf("the %s seat ran on %q, want the one connected provider", seat, pick.Provider) + } + vendor, _, _ := strings.Cut(pick.Model, "/") + labelled[vendor] = append(labelled[vendor], string(seat)) + } + // The checker buys the stronger model when the work is open-ended, so the + // seats genuinely straddle two labs here — which is the behaviour under test, + // and the reason this is asserted on the SEATS rather than on the catalog. + if len(labelled) < 2 { + t.Fatalf("every seat landed on one family (%v), want the crew spanning labs through one key", labelled) + } + for family, seats := range labelled { + t.Logf("ai& served %s to the %s", family, strings.Join(seats, " and ")) + } +} + +// widenCrewCatalog adds rows to the catalog for THIS test only and puts the +// previous one back afterwards. It reads the live catalog rather than a literal +// so a row added to the shared fixture is carried along instead of dropped. +func widenCrewCatalog(t *testing.T, extra ...catalog.Model) { + t.Helper() + previous := CrewCatalog + base := previous() + t.Cleanup(func() { CrewCatalog = previous }) + CrewCatalog = func() []catalog.Model { + return append(append([]catalog.Model(nil), base...), extra...) + } +} + func TestMigratingARetiredCrew(t *testing.T) { dir := crewProfile(t) // A balanced preset applied in the `open` family: all five rows written, diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go index 4c25124121..54c50daba5 100644 --- a/internal/config/sources_test.go +++ b/internal/config/sources_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -27,6 +28,47 @@ func vendoredSource(t *testing.T, id string) modelsource.Source { return modelsource.Source{} } +// A VENDOR THE ROUTER CANNOT WEIGH IS STILL A MODEL A PERSON MAY PICK, and this +// is the seam that decides it. The crew table ([config.crewVendors]) names the +// catalog vendors a connection serves, so a vendor no catalog row names is never +// offered to the crew — but the LISTING is a different question, and the answer +// to it is every id the endpoint published, unfiltered. +// +// ai& is where the two answers differ: motif-technologies is on its listing and +// in no catalog row, so Motif 3 is pickable and unroutable. Reading this as a +// gap would produce the wrong fix twice — dropping the row from the listing, or +// inventing catalog figures for a vendor the router has never heard of. +func TestAModelTheCatalogCannotWeighStillReachesThePicker(t *testing.T) { + // The shape api.aiand.com/v1/models actually answers, trimmed to one model + // the crew can weigh and one it cannot. The richer per-model fields are here + // on purpose: they are what the parse must NOT need in order to keep the id. + body := []byte(`{"object":"list","data":[ + {"id":"zai-org/glm-5.3-flash","name":"zai-org/glm-5.3-flash","context_window":1048550, + "capabilities":["chat","vision","reasoning","tool_calling"], + "reasoning_efforts":["low","high","max"],"reasoning_effort_default":"high"}, + {"id":"motif-technologies/motif-3","name":"Motif-Technologies/Motif-3","context_window":262144, + "capabilities":["chat","reasoning","tool_calling"], + "reasoning_efforts":["none","high"],"reasoning_effort_default":"none"}]}`) + outcome, listed := listedOutcome(body) + if !listed || !outcome.Listed { + t.Fatalf("a well-formed listing did not parse: %+v", outcome) + } + if outcome.Models != 2 { + t.Errorf("listing counted %d models, want 2", outcome.Models) + } + for _, want := range []string{"zai-org/glm-5.3-flash", "motif-technologies/motif-3"} { + if !slices.Contains(outcome.ModelIDs, want) { + t.Errorf("%s did not reach the picker; listing carried %v", want, outcome.ModelIDs) + } + } + // The id is kept WHOLE, org segment and all. Shortening it to `motif-3` is + // the move that would break the send, because a router-shaped provider + // refuses a bare name — the same fact crewVendorWire exists for. + if bare := strings.TrimPrefix(outcome.ModelIDs[1], "motif-technologies/"); bare == outcome.ModelIDs[1] { + t.Error("the vendor segment was taken off a model id that must keep it") + } +} + func TestSourcePersistenceSharesTheProfileAndKeepsItPrivate(t *testing.T) { dir := t.TempDir() if err := WriteAPIKey(dir, "sk-default-1234567890"); err != nil { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index df1cad7656..ce2b3fbb72 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1709,7 +1709,9 @@ status sheet. Change that machine's profile there. ## /connect — your connected accounts `/connect` (or `/connections`) opens the connect panel. Its pinned `providers` group -holds the six built-in model providers plus every one already connected; the account +holds the eight built-in model providers — DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, +ai&, Codex and Ollama — plus **Custom OpenAI-compatible API** and every one already +connected; the account catalog groups follow it. The Codex row says `browser`; enter opens the sign-in road and the waiting card keeps the address available to copy. The other listed providers say what they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom provider: it asks for a diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index e30d4c9ac6..f6ac93f25f 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -7,8 +7,8 @@ something else again: long-running background processes, covered by their own pa ## Add a key — connect a provider, add an api key, use a different provider An api key for another provider, or another model provider, is added here. Open `/connect` or -`/connections`. The `providers` group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, Codex, -Ollama and **Custom OpenAI-compatible API**, followed by any provider already connected and, once +`/connections`. The `providers` group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, +ai&, Codex, Ollama and **Custom OpenAI-compatible API**, followed by any provider already connected and, once a custom provider is connected, a `+ add a provider` row. Codex says `browser`; it signs in a ChatGPT plan instead of asking for an API key. Ollama needs no key. The other named vendors ask for theirs. diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go index 30cb159da0..a05f20f4d2 100644 --- a/internal/modelsource/modelsource.go +++ b/internal/modelsource/modelsource.go @@ -500,6 +500,40 @@ func Vendored() []Source { Listing: ListingNone, ProbeModel: "qwen3.8-flash", Probe: listingProbe(), Preferred: "qwen3.7-plus", }, + { + // OBSERVED 2026-10-02 against api.aiand.com: one door, and + // /models answering 200 with thirteen models under the + // catalog's own vendor/model spelling. Nothing on the wire tells a + // plan from metered credit — the same host, bearer, model and + // request spend the balance either way — so this row claims NO + // second door, the way MiniMax's does not, and returns one only + // when an observed response field, header or error can prove which + // billing product answered. It serves Japan only, so there are no + // regions. Its backend is vLLM over several kinds of GPU rather + // than one named machine, so nothing about an answer names a + // server and there is no ServedAs either. + ID: "aiand", Written: "aiand", Name: "ai&", KeyEnv: "AIAND_API_KEY", + Address: "https://api.aiand.com/v1", KeyShape: LooksLikeAPIKey, + // The probe model is the cheapest lane that still holds a million + // tokens of context ($0.15/$0.25 as listed), and the preference is + // the cheapest lane that also takes a FILE — the rule [Source] states + // is "the vendor's best model, not simply its flagship", and a model + // that refuses an attachment cannot be the best one a person lands + // on. + // + // READ 2026-10-02 off the vendor's own manifest, which is where these + // two figures come from: `zai-org/glm-5.3` is the flagship and takes + // text alone — `attachment: false`, `input: [text]` — so a person who + // dropped a screenshot into their first ai& conversation was refused by + // the model codeaf had just seated them on. `zai-org/glm-5.3-flash` is + // the same family and the same million tokens (1048550 against + // 1048576), takes text, image and video, and costs $0.15/$0.50 against + // the flagship's $1.00/$4.00. Four of the thirteen rows take + // attachments; this is the cheapest of the four that also holds 1M, so + // it is the preference rather than the flagship. + Listing: ListingModels, ProbeModel: "deepseek-ai/deepseek-v4-flash", + Probe: listingProbe(), Preferred: "zai-org/glm-5.3-flash", + }, { ID: "codex", Written: "codex", Name: CodexName, ServedAs: CodexName, Address: "https://chatgpt.com/backend-api/codex", diff --git a/internal/modelsource/modelsource_test.go b/internal/modelsource/modelsource_test.go index 2d4134a39e..d2da91c920 100644 --- a/internal/modelsource/modelsource_test.go +++ b/internal/modelsource/modelsource_test.go @@ -128,7 +128,7 @@ func TestUnqualifiedIdsStayOnTheDefaultService(t *testing.T) { func TestVendoredRowsCarryTheCodexServiceInItsDecidedPlace(t *testing.T) { // C12: Codex is a model service whose models are qualified on every surface. rows := Vendored() - want := []string{"deepseek", "z-ai", "moonshot", "minimax", "qwen", "codex", "ollama", "custom"} + want := []string{"deepseek", "z-ai", "moonshot", "minimax", "qwen", "aiand", "codex", "ollama", "custom"} if len(rows) != len(want) { t.Fatalf("vendored rows = %d, want %d", len(rows), len(want)) } @@ -140,16 +140,34 @@ func TestVendoredRowsCarryTheCodexServiceInItsDecidedPlace(t *testing.T) { t.Errorf("row %s probe timeout = %s, want %s", rows[i].ID, rows[i].Probe.Timeout, ProbeTimeout) } } - if !rows[6].KeyOptional { + // OLLAMA IS NAMED, NEVER COUNTED. A row that may omit its key is a + // statement about a service, so it is read by identity — an insertion above + // it moves it down an index without saying anything about it. + ollama, ok := vendoredByID(rows, "ollama") + if !ok { + t.Fatal("the vendored rows name no ollama") + } + if !ollama.KeyOptional { t.Fatal("only Ollama may omit its key") } - for index, row := range rows { - if index != 6 && row.KeyOptional { + for _, row := range rows { + if row.ID != "ollama" && row.KeyOptional { t.Fatalf("%s unexpectedly accepts a blank key", row.ID) } } } +// vendoredByID finds one vendored row by its own id, the way a caller that +// cares about a service rather than about its position in the table finds it. +func vendoredByID(rows []Source, id string) (Source, bool) { + for _, row := range rows { + if row.ID == id { + return row, true + } + } + return Source{}, false +} + // THE ROWS RECORD THE BEST KNOWN TRUTH, AND OBSERVATION OUTRANKS THE SURVEY. // This law was written pinning each row to B-provider-landscape.md, which is // right only until somebody watches the endpoint answer. Z.ai is the worked @@ -180,6 +198,14 @@ func TestVendoredListingHintsAndProbeModelsMatchTheProviderSurvey(t *testing.T) {"moonshot", ListingNone, "kimi-k2.7-code", "kimi-k2.7-code"}, {"minimax", ListingNone, "MiniMax-M3", "MiniMax-M3"}, {"qwen", ListingNone, "qwen3.8-flash", "qwen3.7-plus"}, + // OBSERVED on 2026-10-02 with a live key against api.aiand.com, and read + // again off https://api.aiand.com/v1/api.json: /models answered 200 + // with thirteen models, so the hint is ListingModels rather than a guess. + // The probe is the cheapest lane that still holds a million tokens + // ($0.15/$0.25) and the preference the cheapest that also takes a file + // ($0.15/$0.50) — NOT the flagship, which the manifest answers 200 for and + // marks `attachment: false`. Both figures are the listing's own. + {"aiand", ListingModels, "deepseek-ai/deepseek-v4-flash", "zai-org/glm-5.3-flash"}, {"codex", ListingNone, "", "gpt-5.5"}, {"ollama", ListingModels, "", ""}, {"custom", ListingModels, "", ""}, diff --git a/internal/tui3/crewpanel_test.go b/internal/tui3/crewpanel_test.go index faa6d2c6d1..c05b573291 100644 --- a/internal/tui3/crewpanel_test.go +++ b/internal/tui3/crewpanel_test.go @@ -27,7 +27,7 @@ func crewLab(t *testing.T) (*app, string) { t.Helper() for _, name := range []string{config.APIKeyEnv, "OPENAI_API_KEY", config.ModelEnv, config.PlanModelEnv, config.CheckModelEnv, "CODEAF_BASE_URL", "DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY", - "MINIMAX_API_KEY", "DASHSCOPE_API_KEY"} { + "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", "AIAND_API_KEY"} { t.Setenv(name, "") } a, dir := sheetApp(t) diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index c50eee1d0c..963613215d 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -177,7 +177,7 @@ func modelServiceTestApp(t *testing.T, dir string, model string, sources modelso func modelServiceTestAppWithAgent(t *testing.T, dir string, model string, sources modelsource.Set, models []Model, agent Agent) *app { t.Helper() - for _, env := range []string{"DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY"} { + for _, env := range []string{"DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY", "AIAND_API_KEY"} { t.Setenv(env, "") } t.Setenv("CODEAF_HOME", t.TempDir()) From 623dbbfc9b982184f2514c59fa68af64fcdce226 Mon Sep 17 00:00:00 2001 From: fenil modi Date: Fri, 2 Oct 2026 04:25:23 +0000 Subject: [PATCH 2/3] changelog: name the ai& entry for pull request 1 docs/rules/changelog.md puts the order plainly: push the branch, open the pull request, let `check` go red once, then add the entry with the number that failure names. The entry was written against a guessed 1736, and the pull request is 1. Renaming the file is the whole change. `make changelog-check` reads 9 entries, all well formed. One sentence inside it was also untrue once the crew tables landed: it still said the change was one row and nothing else, which stopped being the case when internal/config/crew.go grew two. It now says which two files carry production code. --- ...tive-provider.md => 1-aiand-native-provider.md} | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) rename docs/changes/unreleased/{1736-aiand-native-provider.md => 1-aiand-native-provider.md} (93%) diff --git a/docs/changes/unreleased/1736-aiand-native-provider.md b/docs/changes/unreleased/1-aiand-native-provider.md similarity index 93% rename from docs/changes/unreleased/1736-aiand-native-provider.md rename to docs/changes/unreleased/1-aiand-native-provider.md index acde1cd200..098fdbc714 100644 --- a/docs/changes/unreleased/1736-aiand-native-provider.md +++ b/docs/changes/unreleased/1-aiand-native-provider.md @@ -1,7 +1,7 @@ --- kind: added title: ai& is offered as a provider of its own, asked for by key and nothing else -pr: 1736 +pr: 1 surface: [chat, engine] invalidates: - >- @@ -30,15 +30,19 @@ One prepaid credit spends any model in ai&'s list, and that list reaches several labs at once, so a single key covers models a person would otherwise open four different accounts for. -This change is one row in `modelsource.Vendored()` and nothing else. Every other -surface — the connect panel, the providers group, the picker group, the Providers -tab and `codeaf connect aiand` — is derived from that catalog, and +The PROVIDER is one row in `modelsource.Vendored()`, and every surface a person +touches is already derived from that catalog — the connect panel, the providers +group, the picker group, the Providers tab and `codeaf connect aiand`. `Config.Direct` already switches off the router's lane sheet, receipt fetch, price ceiling and routing vocabulary for a vendor reached directly, so a direct vendor stays a base-URL swap. `internal/session` needed no change either: `clientdoor.go` keys its adapter pool on the resolved connection and strips the service segment from the id it sends, which is why ai&'s own two-segment ids -(`zai-org/glm-5.3`, `deepseek-ai/deepseek-v4.1-flash`) already reach it whole. +(`zai-org/glm-5.3-flash`, `deepseek-ai/deepseek-v4.1-flash`) already reach it +whole. + +`internal/config/crew.go` is the only other file with production code in it, and +it takes two tables rather than any new machinery — described below. ai& lists its models — `GET /v1/models` answers — so the connect line carries the count that came back, for example `aiand is connected · 13 models`, and `/model` From e1ce159a4c51881ad1ff9441415e46c7f44e87bd Mon Sep 17 00:00:00 2001 From: fenil modi Date: Fri, 2 Oct 2026 04:27:59 +0000 Subject: [PATCH 3/3] changelog: name the ai& entry for pull request 1737 The entry was first written against a guessed 1736, then renamed to 1 when the pull request was opened on the fork. It is now opened upstream, where the number is 1737, and that is the number the entry has to carry: the file name is what the roll-up reads, and a merge takes the upstream number. `make changelog-check` reads 9 entries, all well formed. --- .../{1-aiand-native-provider.md => 1737-aiand-native-provider.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{1-aiand-native-provider.md => 1737-aiand-native-provider.md} (100%) diff --git a/docs/changes/unreleased/1-aiand-native-provider.md b/docs/changes/unreleased/1737-aiand-native-provider.md similarity index 100% rename from docs/changes/unreleased/1-aiand-native-provider.md rename to docs/changes/unreleased/1737-aiand-native-provider.md