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())