From e74747bb0a2aaa5d4fad99554e42983d858b9426 Mon Sep 17 00:00:00 2001 From: fenil modi Date: Fri, 2 Oct 2026 03:30:07 +0000 Subject: [PATCH] connect: ai& is a provider of its own, asked for a key and nothing else 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. It needed exactly one row in modelsource.Vendored() and the rest of the product follows from that one fact: the connect panel, the providers group, the picker group, the Providers tab and `codeaf connect aiand` are all derived from the vendored catalog. Config.Direct already switches off the router's lane sheet, generation-receipt fetch, price ceiling and routing vocabulary, so the provider layer needed no change and a direct vendor stays a base-URL swap. internal/session needed none either — clientdoor.go keys its adapter pool on the resolved connection. TWO FACTS THE CATALOG ALONE DOES NOT GIVE. ai& is the first connection here that is itself a router: its ids already name the lab (`zai-org/glm-5.3`, `deepseek-ai/deepseek-v4.1-flash`), so a send is the provider segment over the vendor's own id. crewVendors maps a connection to the CATALOG's vendor words, because that is what the router weighs — but the catalog says `deepseek/` and `z-ai/` where ai& says `deepseek-ai/` and `zai-org/`. crewVendorWire carries that rename on the send. It is not a tidiness fix: a bare name was asked for on 2026-10-02 and answered 404 model_not_found for deepseek-v4-flash, glm-5.3-flash and kimi-k3, while the qualified ids answered 200. The same listing means `motif-technologies` and `google` are organisations no connected provider carries, so crew routing reaches neither. They are still pickable by hand, and the manual says so rather than implying coverage. The one-door claim is deliberate and says why in the row: nothing on the wire separates a plan from metered credit, which is MiniMax's position too. A 402 insufficient_credits is already read correctly by paymentrefusal's generic 402 rule. Verified live against the API on 2026-10-02: the listing, a streamed reply with its usage frame, tool calling, `max_tokens`, `stream_options`, and the refusal of a bare model name. Also observed and recorded because the docs imply otherwise: X-Cost is advertised in access-control-expose-headers and never sent, so a direct provider's cost cannot be read off a response. --- README.md | 6 +- docs/GUIDE.md | 5 +- .../unreleased/1736-aiand-native-provider.md | 47 +++++ internal/config/crew.go | 61 ++++++- internal/config/crew_test.go | 49 ++++- internal/manual/chat/commands.md | 12 +- .../manual/chat/running-from-the-terminal.md | 8 +- internal/manual/chat/services.md | 67 +++++-- internal/manual/chat_test.go | 6 + internal/modelsource/modelsource.go | 21 +++ internal/modelsource/modelsource_test.go | 32 +++- internal/session/clientdoor_wire_test.go | 168 ++++++++++++++++++ internal/session/plan_doors_test.go | 23 +++ internal/tui3/crewpanel_test.go | 2 +- internal/tui3/modelservices_test.go | 2 +- 15 files changed, 479 insertions(+), 30 deletions(-) create mode 100644 docs/changes/unreleased/1736-aiand-native-provider.md 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. 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 -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())