diff --git a/README.md b/README.md
index 4f94030804..ad7a7673b2 100644
--- a/README.md
+++ b/README.md
@@ -73,8 +73,8 @@ To build it yourself: `git clone`, `make build`, `bin/codeaf`
On first start it connects OpenRouter in your browser, or takes a key. Codex signs in
-a ChatGPT plan from `/connect` or `codeaf connect codex`; DeepSeek, GLM, Kimi, MiniMax
-and Qwen take keys; Ollama needs none.
+a ChatGPT plan from `/connect` or `codeaf connect codex`; DeepSeek, GLM, Kimi, MiniMax,
+Qwen and ai& take keys; Ollama needs none.
## One window for every project
@@ -187,7 +187,7 @@ request goes to the provider that has been fastest for that kind of call.
-Providers built in: OpenRouter, DeepSeek, GLM, Kimi, MiniMax, Qwen, Codex through a
+Providers built in: OpenRouter, DeepSeek, GLM, Kimi, MiniMax, ai&, Qwen, Codex through a
ChatGPT plan, Ollama and any OpenAI-compatible endpoint.
## Model Pool
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 405fe2778d..01e885557d 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -321,8 +321,9 @@ The second page is `Daily limit` and `Chat model`.
The chat model resolves from `--model`, then saved `model.talk`, then `CODEAF_MODEL`,
then `~deepseek/deepseek-v4-flash-latest`. The last value is a floating alias. Besides
-OpenRouter, the connection screen supports DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba
-Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible provider.
+OpenRouter, the connection screen supports DeepSeek, Z.ai, Moonshot, MiniMax, ai&,
+Alibaba Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible
+provider.
The same supported providers can be managed without opening the chat with `codeaf
connect` and `codeaf disconnect`; a qualified slug such as
`qwen/` selects its provider.
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..ac8ce0586a
--- /dev/null
+++ b/docs/changes/unreleased/1736-aiand-native-provider.md
@@ -0,0 +1,47 @@
+---
+kind: added
+title: ai& is offered as a provider of its own, asked for by key and nothing else
+pr: 1736
+surface: [chat, engine, docs]
+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`.
+ - >-
+ A provider with one billing door had to be discovered by the person reading
+ the code — MiniMax's page said why it made no plan claim and said nothing
+ about which other providers were in the same position. ai& is now named
+ wherever that shape is described, and it asks for `your key` with no region
+ choice, exactly like DeepSeek and MiniMax.
+ - >-
+ Every other provider's models were reached by an id codeaf invented a prefix
+ for. ai&'s own list already names the lab that built each model
+ (`zai-org/glm-5.3`, `deepseek-ai/deepseek-v4.1-flash`), so those ids are kept
+ whole behind the `aiand/` segment rather than shortened.
+ - >-
+ `/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.
+---
+
+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.
+
+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.
+
+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.
diff --git a/internal/config/crew.go b/internal/config/crew.go
index 41fc87d2fe..43793b2a95 100644
--- a/internal/config/crew.go
+++ b/internal/config/crew.go
@@ -690,11 +690,48 @@ 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: thirteen models under seven prefixes, which are six catalog vendors —
+ // the seventh, motif-technologies, is not one this catalog carries. 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 +837,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..942fb135ae 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,52 @@ 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.
+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)
+ }
+ routes := map[string]string{}
+ for _, c := range CrewCandidatesAt(dir) {
+ for _, r := range c.Routes {
+ if r.Provider == "aiand" {
+ routes[c.Model.ID] = r.Send
+ }
+ }
+ }
+ 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",
+ }
+ for model, send := range want {
+ if routes[model] != send {
+ t.Errorf("%s through ai& sends %q, want %q", model, routes[model], send)
+ }
+ }
+ // 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")
+ }
+}
+
func TestMigratingARetiredCrew(t *testing.T) {
dir := crewProfile(t)
// A balanced preset applied in the `open` family: all five rows written,
diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md
index df1cad7656..5da9003209 100644
--- a/internal/manual/chat/commands.md
+++ b/internal/manual/chat/commands.md
@@ -1709,10 +1709,14 @@ 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
-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
+holds the eight named 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: ai&, DeepSeek and MiniMax ask for `your key`
+and nothing else, Ollama asks for nothing, and Z.ai, Moonshot and Alibaba Qwen ask for a
+region before the key. Pick a row and connect it. There is no argument form.
+**Custom OpenAI-compatible API** connects a custom provider: it asks for a
base URL, then a name of your own with the host's own spelling pre-filled (`127.0.0.1`
becomes `127-0-0-1`). It asks for a key only if the model-list address answers 401 or 403.
Several custom providers sit beside each other,
diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md
index 405a2cc301..090d64bcbd 100644
--- a/internal/manual/chat/running-from-the-terminal.md
+++ b/internal/manual/chat/running-from-the-terminal.md
@@ -221,14 +221,18 @@ machine, the next line gives the tunnel to run before opening that address here:
ssh -L 1455:localhost:1455
```
-If that sign-in chose port 1457 instead, the printed command uses 1457. DeepSeek and
-MiniMax take a key through the same checked connection as `/connect`; Ollama takes none.
+If that sign-in chose port 1457 instead, the printed command uses 1457. DeepSeek, MiniMax
+and ai& take a key through the same checked connection as `/connect`; Ollama takes none.
Z.ai, Moonshot and Qwen take a key and also need `--region intl` or `--region cn`. A key
is read from stdin when it is piped, or asked for without echo on a terminal. A new custom
provider is created only in the chat: an unknown custom name says it is not a provider this
profile knows. Once the chat has created one, `codeaf connect ` can reconnect
that instance with a key.
+`codeaf connect aiand` is the one line that adds ai&: the provider it saves is named
+`aiand`, so that is the word the command and every model id take. It asks for a key and no
+`--region`, because ai& has no region to choose.
+
`codeaf disconnect ` says `forget a provider and the key or sign-in behind it`
in its help. The command forgets that provider and its key or sign-in. Neither
command sends a prompt, calls a model or adds model spend. They do not print keys or
diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md
index e30d4c9ac6..c329bace4d 100644
--- a/internal/manual/chat/services.md
+++ b/internal/manual/chat/services.md
@@ -7,7 +7,7 @@ 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,
+`/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
@@ -63,24 +63,27 @@ the variable.
`--at`, connecting a provider is absent because the profile behind the conversation is
not the local profile the panel could write.
-## Use my own DeepSeek key — connecting DeepSeek, GLM, Kimi, Qwen or MiniMax directly
+## Use my own DeepSeek key — connecting DeepSeek, GLM, Kimi, Qwen, MiniMax or ai& directly
-Open `/connect` and choose the vendor in the `models` group. DeepSeek and MiniMax open
-`your key` directly. Z.ai, Moonshot and Alibaba Qwen first open `your region` as a
+Open `/connect` and choose the vendor in the `models` group. DeepSeek, MiniMax and ai&
+open `your key` directly. Z.ai, Moonshot and Alibaba Qwen first open `your region` as a
choice with `International` under the cursor and `China` below it; a region is never
typed. Up and down, or `ctrl+p` and `ctrl+n`, move the cursor. A letter jumps to a
region whose name starts with it, enter takes the row under the cursor and opens
`your key`, and esc returns to the provider row with nothing saved. The same choice
opens when reconnecting one of these providers from its Providers row in `/settings`.
Z.ai is the direct provider for GLM and Moonshot is the direct provider for Kimi.
-MiniMax, Ollama and **Custom OpenAI-compatible API** are single-door providers. MiniMax makes no plan
-claim because its plan and metered traffic currently have no wire-level difference
-codeaf can use to prove which balance answered.
+MiniMax, ai&, Ollama and **Custom OpenAI-compatible API** are single-door providers. MiniMax and ai& make no plan
+claim because their plan and metered traffic have no wire-level difference codeaf can
+use to prove which balance answered; ai& spends one prepaid credit per token, so a
+second door would be a label codeaf cannot check.
A provider name cannot be confused with the author part of a model already on the default
provider. When `deepseek` is already an author there, codeaf connects the direct provider
under `deepseek-direct` in that same attempt. The region and key are not asked for twice,
-and its models read `deepseek-direct/`.
+and its models read `deepseek-direct/`. ai& is not one of those: no model
+author on the default provider is `aiand`, so it connects under the name `aiand` and its
+models read `aiand/`.
## Why is my provider called z-ai-direct — I connected Z.ai, the name changed
@@ -89,11 +92,50 @@ author. codeaf appends `-direct` and finishes the connection in the same attempt
region and key are not asked for twice. The connect line tells you the name it used, for
example `z-ai-direct is connected · coding plan · 4 models`, and those models read
`z-ai-direct/`. DeepSeek follows the same rule: it becomes `deepseek-direct`,
-and its models read `deepseek-direct/`.
+and its models read `deepseek-direct/`. ai& collides with no author, so it keeps
+the name `aiand` and never becomes `aiand-direct`.
+
+## ai& — one key for open models from several labs
+
+ai& sells one prepaid credit, spends it per token, and lets that credit buy any model in its
+list. So one key reaches models built by several different labs at once — the list is
+organised by who made the model, not by one house. Its address is
+`https://api.aiand.com/v1`, and it answers both the OpenAI-shaped and the
+Anthropic-shaped chat path, so one key covers either. A key starts `sk-` and may also be
+the name of an environment variable, such as `$AIAND_API_KEY`.
+
+It lists its models, 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 instead of
+asking you to type an id. Its list is organised by organisation and moves as the vendor adds
+and retires them, so the count in that line is whatever answered that day rather than a
+number codeaf remembers. A model from an organisation the list no longer carries is simply
+gone from that group; nothing here claims an organisation is served forever.
+
+Most of what it serves is a model codeaf already knows from somewhere else: on the list
+seen when this page was written, five of the seven organisations were ones a connected
+provider already carries — DeepSeek, Z.ai, Moonshot and Alibaba Qwen, plus OpenAI behind
+Codex — so the same families, GLM and Kimi among them, come through the one ai& key. Two
+are not: `motif-technologies` is one codeaf knows nothing about, and `google` is an
+organisation the catalog knows while no connected provider carries it. Models under either
+are in ai&'s list and you can still pick them, but nothing here chooses one for a task or
+plans a crew around them.
+
+Every id in that list already names the lab that built the model, so it is the whole model
+id codeaf wants and it is not shortened. Connecting ai& moves this conversation onto one of
+the models it listed, in the same moment, and the line says so the way it does for any
+provider.
+
+There is no plan door to pick and no region to choose: one key, one credit, one door. A run
+with nothing left on that credit is answered by ai& with a `402` and `insufficient_credits`,
+which is the balance speaking rather than a bad key.
+
+Inference runs in Japan and ai& markets data residency on that. That is the vendor's own
+claim about its own infrastructure, and it is why there is no region to pick; codeaf
+promises nothing about where a request goes.
## Connect a provider — what is asked for, and what codeaf checks before it saves anything
-Open `/connect` and choose a row in `providers`. DeepSeek asks for `your key`. Z.ai,
+Open `/connect` and choose a row in `providers`. DeepSeek and ai& ask for `your key`. Z.ai,
Moonshot and Alibaba Qwen ask `your region` with one row per region: `International`
is first and starts under the cursor, then `China`. Up and down, or `ctrl+p` and
`ctrl+n`, move the cursor; a letter jumps to a region whose name starts with it;
@@ -207,6 +249,11 @@ where the model can be reached. With two or more connected providers, `/model` s
heading for each provider, default first, in the order shown in the Providers tab. A
custom provider's heading is the name you gave it.
+One provider's ids already arrive qualified, so its qualified form carries two segments:
+`aiand/zai-org/glm-5.3`. The first still names the provider that can reach it, and
+everything after it is that provider's own id, unchanged — `zai-org/glm-5.3`,
+`deepseek-ai/deepseek-v4.1-flash`. Nothing after the first segment is ever shortened.
+
The status line uses the same spelling: an unqualified default-provider id, and
`/` for every other provider. It does not shorten
`ollama/llama3.2:latest` to `llama3.2:latest`, because two providers may publish the
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go
index ca1956ba93..5bf426c8f5 100644
--- a/internal/manual/chat_test.go
+++ b/internal/manual/chat_test.go
@@ -145,6 +145,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"is codeaf supported by zhipu", "services"},
{"how do I reconnect a model provider", "services"},
{"I exported the model provider key after the engine started", "services"},
+ // ai& (services.md). A person meets it under its display name on the
+ // row and under `aiand` everywhere else — in the connect line, in the
+ // picker heading and in every model id — so both spellings are asked.
+ {"how do I connect to ai&", "services"},
+ {"can I use my aiand key", "services"},
+ {"why does aiand list models from different labs", "services"},
{"why does /connect say connections are unavailable", "accounts"},
{"connect says unavailable on my own machine", "accounts"},
{"credentials.json is damaged but where are my models", "accounts"},
diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go
index 30cb159da0..9104f3f6a9 100644
--- a/internal/modelsource/modelsource.go
+++ b/internal/modelsource/modelsource.go
@@ -500,6 +500,27 @@ 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 flagship ($1.00/$4.00, also 1M) rather than the cheapest,
+ // because a seat that can afford the flagship should have it.
+ Listing: ListingModels, ProbeModel: "deepseek-ai/deepseek-v4-flash",
+ Probe: listingProbe(), Preferred: "zai-org/glm-5.3",
+ },
{
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..0dce86bfa8 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,12 @@ 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:
+ // /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 and the preference the flagship,
+ // both read off that same listing rather than invented.
+ {"aiand", ListingModels, "deepseek-ai/deepseek-v4-flash", "zai-org/glm-5.3"},
{"codex", ListingNone, "", "gpt-5.5"},
{"ollama", ListingModels, "", ""},
{"custom", ListingModels, "", ""},
diff --git a/internal/session/clientdoor_wire_test.go b/internal/session/clientdoor_wire_test.go
index f9af4ec444..7a8f75cadd 100644
--- a/internal/session/clientdoor_wire_test.go
+++ b/internal/session/clientdoor_wire_test.go
@@ -8,7 +8,9 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
"path/filepath"
+ "strings"
"sync"
"sync/atomic"
"testing"
@@ -226,6 +228,172 @@ func TestTheSessionReachesTheSourceThatServesItsModel(t *testing.T) {
assertOrdinaryClientDoorCall(t, server.call(t, 0), "direct-conversation-key", "stub/conversation")
}
+// clientDoorHost is the Host header a request to address arrives with, which is
+// neither the scheme nor the base path. It is read back from the address rather
+// than taken from a stub's own idea of itself, because the claim under test is
+// WHICH host was asked and a stub that reported its own host could not fail.
+func clientDoorHost(address string) string {
+ parsed, err := url.Parse(address)
+ if err != nil {
+ return address
+ }
+ return parsed.Host
+}
+
+// aiandClientDoorSource is the vendored ai& row as the session door is asked to
+// handle it: ONE billing door, no regions, no metered overflow beside it, an
+// OpenAI-shaped sk- key under AIAND_API_KEY, and a /models listing the vendor
+// serves itself at its own base.
+//
+// IT IS SPELLED OUT HERE RATHER THAN READ FROM modelsource.Vendored, because a
+// test that reads the registry back proves only that the registry is
+// reachable. This one states the SHAPE the session has to be right about, so a
+// change to the row that mattered — a second door, a bare slug, a key made
+// optional — fails here instead of quietly redefining what was promised.
+func aiandClientDoorSource(address string) modelsource.Source {
+ return modelsource.Source{
+ ID: "aiand", Written: "aiand", Name: "ai&", Address: address,
+ KeyEnv: "AIAND_API_KEY", KeyShape: modelsource.LooksLikeAPIKey,
+ Listing: modelsource.ListingModels,
+ Probe: modelsource.Probe{Address: "/models", Method: http.MethodGet, Accepts: []int{http.StatusOK}},
+ }
+}
+
+// ai& is THE INTERESTING DIRECT VENDOR FOR THE PREFIX. Every other direct row
+// serves bare ids, so stripping the service's written name is the whole job and
+// a double prefix is invisible. This vendor publishes its models as
+// `vendor/model` — `zai-org/glm-5.3` — so the qualified id a person selects is
+// THREE segments and the wire slug is a TWO-segment id with one removed. The
+// split happens on the FIRST segment alone, so the bare slug keeps its own
+// vendor segment, and the failure mode of getting that wrong is a slug no vendor
+// publishes (the router's 400 about a model nobody serves), not a wrong host.
+//
+// THE OTHER HALF IS WHAT A DIRECT VENDOR IS NEVER ASKED FOR. /models is the
+// catalog the CONNECT step asks for and /endpoints is the router's lane sheet;
+// both are OpenRouter machinery, so a turn aimed at a direct service must put
+// exactly one POST on exactly one road.
+func TestTheClientDoorReachesAiandWithItsBareVendorSlug(t *testing.T) {
+ const (
+ key = "aiand-wire-key"
+ wireModel = "zai-org/glm-5.3"
+ )
+ // The id a person selects, and the two spellings it must NOT reach the wire
+ // as: the qualified one, and the slug with the written name re-attached.
+ qualified := "aiand/" + wireModel
+
+ t.Run("reaches the aiand host with the bare vendor slug", func(t *testing.T) {
+ host := sourcestub.New(wireModel)
+ t.Cleanup(host.Close)
+ // The default member is the DEAD address [directClientDoorSources]
+ // installs beside its service, so a turn that fell through to it is a
+ // connection error rather than a silent success on somebody else's key.
+ // It is spelled out rather than borrowed because that helper wants the
+ // chat stub it would put on aiand's own host.
+ unreachable := "http://127.0.0.1:1"
+ agent, err := New(Config{
+ Workspace: t.TempDir(), Model: qualified,
+ Sources: modelsource.NewSet(
+ modelsource.Connected{Source: modelsource.DefaultSource(unreachable), Key: "default-aiand-key", Address: unreachable},
+ modelsource.Connected{
+ Source: aiandClientDoorSource(host.URL()), Key: key, Address: host.URL(),
+ },
+ ),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = agent.Close() })
+ drainTurn(t, agent, "answer from ai&")
+
+ // The session keeps the SERVICE-QUALIFIED identity the person chose even
+ // though the slug it puts on the wire has no service name in it.
+ if agent.Model() != qualified {
+ t.Fatalf("session model = %q, want its service-qualified identity %q", agent.Model(), qualified)
+ }
+
+ // ONE request, and it is the chat POST. A GET /v1/models here would be
+ // the session doing config's job; this host records it either way.
+ requests := host.Requests()
+ if len(requests) != 1 {
+ t.Fatalf("the aiand host received %d requests, want only the turn's completion: %+v", len(requests), requests)
+ }
+ request := requests[0]
+ if request.Method != http.MethodPost || request.Path != "/v1"+modelsource.ChatCompletionsPath {
+ t.Fatalf("the aiand host received %s %s, want POST /v1%s", request.Method, request.Path, modelsource.ChatCompletionsPath)
+ }
+ if request.Host != clientDoorHost(host.URL()) {
+ t.Fatalf("request Host = %q, want aiand's own host %q", request.Host, clientDoorHost(host.URL()))
+ }
+ if request.Bearer != "Bearer "+key {
+ t.Fatalf("Authorization = %q, want Bearer %s — aiand's own key, never the default service's", request.Bearer, key)
+ }
+ // The identity codeaf gives a service it reaches itself, which is the
+ // whole of what a direct vendor is told about who is calling.
+ if request.Agent != provider.DirectUserAgent {
+ t.Fatalf("User-Agent = %q, want %q — a direct vendor is not sent the router attribution", request.Agent, provider.DirectUserAgent)
+ }
+
+ var envelope struct {
+ Model string `json:"model"`
+ }
+ if err := json.Unmarshal(request.Body, &envelope); err != nil {
+ t.Fatalf("decode the aiand request body: %v (%s)", err, request.Body)
+ }
+ if envelope.Model != wireModel {
+ t.Fatalf("wire model = %q, want the bare vendor slug %q with only the service's written name removed", envelope.Model, wireModel)
+ }
+ if strings.Contains(envelope.Model, "aiand/") {
+ t.Fatalf("wire model = %q, want NO copy of the service name on it — a direct vendor serves %q", envelope.Model, wireModel)
+ }
+ })
+
+ t.Run("a direct vendor is asked for nothing but the chat road", func(t *testing.T) {
+ // A stub that fails the test on ANY request, so absence is proved rather
+ // than inferred: a leaked /models or /endpoints GET to the default is
+ // named with its method and path instead of vanishing into a 404.
+ forbidden := newForbiddenClientDoorServer(t)
+ host := newClientDoorServer(t, "done")
+ agent, err := New(Config{
+ Workspace: t.TempDir(), Model: qualified,
+ Sources: modelsource.NewSet(
+ modelsource.Connected{Source: modelsource.DefaultSource(forbidden.URL), Key: "default-aiand-key", Address: forbidden.URL},
+ modelsource.Connected{Source: aiandClientDoorSource(host.URL), Key: key, Address: host.URL},
+ ),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = agent.Close() })
+ drainTurn(t, agent, "answer from ai&")
+
+ // THE COMPATIBILITY CONTROL. A plain service row on an ordinary slug
+ // changes nothing about the request: the slug is the bare id, and
+ // codeaf invented neither a reasoning level nor a provider.max_price.
+ assertOrdinaryClientDoorCall(t, host.call(t, 0), key, wireModel)
+
+ // The ledger is the stronger claim than the one call above, because this
+ // stub records EVERYTHING that arrives, 404s included. Routing is on by
+ // default, so a session whose model were not treated as direct would put
+ // its lane-sheet GET on this host; its absence is asserted here.
+ requests := host.allRequests()
+ if len(requests) != 1 {
+ t.Fatalf("the direct vendor received %d requests, want only the chat completion: %+v", len(requests), requests)
+ }
+ for _, request := range requests {
+ if request.method != http.MethodPost || request.path != modelsource.ChatCompletionsPath {
+ t.Fatalf("a direct vendor received %s %s; /models and /endpoints are OpenRouter machinery and reach it never",
+ request.method, request.path)
+ }
+ if request.authorization != "Bearer "+key {
+ t.Fatalf("request Authorization = %q, want Bearer %s", request.authorization, key)
+ }
+ }
+ if got := forbidden.calls.Load(); got != 0 {
+ t.Fatalf("the default service received %d requests; a qualified aiand model must not fall through to it", got)
+ }
+ })
+}
+
// Every model chooses its own service before its slug is put on the request.
// This drives the real Agent through the three tier families that made the
// hand-run leak visible: reflex, worker, and mastermind.
diff --git a/internal/session/plan_doors_test.go b/internal/session/plan_doors_test.go
index bc8941e4f3..8e2650f42f 100644
--- a/internal/session/plan_doors_test.go
+++ b/internal/session/plan_doors_test.go
@@ -199,13 +199,26 @@ func TestOnlyAnExplicitReconnectRebindsTheDoor(t *testing.T) {
}
func TestEveryOneDoorServiceKeepsItsHostAndBearer(t *testing.T) {
+ // keyEnv and shape carry the key DESCRIPTION a vendored row declares and a
+ // custom or local row has no opinion about. Both are zero for every row that
+ // did not set them, and a nil KeyShape accepts any non-blank value, so
+ // declaring them changes nothing the older rows prove.
for _, testCase := range []struct {
id, written, key string
optional bool
+ keyEnv string
+ shape func(string) bool
}{
{id: "deepseek", written: "deepseek-direct", key: "deepseek-test-key"},
{id: "ollama", written: "ollama", optional: true},
{id: "custom", written: "something-local", key: "custom-test-key"},
+ // ai& is one billing door with no metered overflow beside it: the plan
+ // and pay-as-you-go split exists on the vendors that sell both, and a
+ // service that sells one has nothing for the door walk to choose
+ // between. It is not key-optional either — an OpenAI-shaped sk- key is
+ // the only way in — so a blank one has to fail rather than reach the host.
+ {id: "aiand", written: "aiand", key: "aiand-one-door-test-key",
+ keyEnv: "AIAND_API_KEY", shape: modelsource.LooksLikeAPIKey},
} {
t.Run(testCase.id, func(t *testing.T) {
host := sourcestub.New("one-model")
@@ -213,6 +226,7 @@ func TestEveryOneDoorServiceKeepsItsHostAndBearer(t *testing.T) {
service := modelsource.Connected{
Source: modelsource.Source{
ID: testCase.id, Written: testCase.written, Address: host.URL(),
+ KeyEnv: testCase.keyEnv, KeyShape: testCase.shape,
KeyOptional: testCase.optional, Listing: modelsource.ListingModels,
},
Key: testCase.key, Address: host.URL(),
@@ -223,6 +237,15 @@ func TestEveryOneDoorServiceKeepsItsHostAndBearer(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("one-door turn requests = %+v", calls)
}
+ // The turn is answered by the one host and no other, so every call
+ // that reached it carries the key this service was connected with
+ // AND the identity codeaf gives a service it reaches itself. A
+ // vendor that is not the router gets the product's own name and no
+ // router attribution, and that is the whole of the direct road.
+ if calls[0].Host != clientDoorHost(host.URL()) {
+ t.Fatalf("request reached host %q, want the service's own host %q",
+ calls[0].Host, clientDoorHost(host.URL()))
+ }
assertRequestsCarry(t, calls, testCase.key)
})
}
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())