diff --git a/docs/changes/unreleased/1737-aiand-native-provider.md b/docs/changes/unreleased/1737-aiand-native-provider.md new file mode 100644 index 0000000000..098fdbc714 --- /dev/null +++ b/docs/changes/unreleased/1737-aiand-native-provider.md @@ -0,0 +1,124 @@ +--- +kind: added +title: ai& is offered as a provider of its own, asked for by key and nothing else +pr: 1 +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. + +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-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` +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 8cb8f12f27..0d3241d1b8 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1721,7 +1721,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())