Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ To build it yourself: `git clone`, `make build`, `bin/codeaf`
</details>

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

Expand Down Expand Up @@ -187,7 +187,7 @@ request goes to the provider that has been fastest for that kind of call.

<img src="assets/readme/screens/models.webp" alt="The right model for each call: the spend page showing what ran it, by model and role: glm-5.3, deepseek-v4-flash and qwen3.8-27b with calls, tokens and dollars" width="100%">

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
Expand Down
5 changes: 3 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model>` selects its provider.
Expand Down
47 changes: 47 additions & 0 deletions docs/changes/unreleased/1736-aiand-native-provider.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 59 additions & 2 deletions internal/config/crew.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <vendor>/<name> 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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion internal/config/crew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions internal/manual/chat/running-from-the-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,18 @@ machine, the next line gives the tunnel to run before opening that address here:
ssh -L 1455:localhost:1455 <that machine>
```

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 <its name>` 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 <provider>` 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
Expand Down
Loading