From 5d4e0cefaec12c6f0557e145edb1d13eab20732c Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 3 Sep 2026 14:21:44 +0500 Subject: [PATCH 1/4] feat(x): add aimlapi.com as a named model provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI/ML API is already reachable through the openai-compatible flavor — a user can paste https://api.aimlapi.com/v1 and it works. The reason to name it is its catalog. GET /v1/models there answers with every endpoint the account can reach, not just chat: 936 entries across 15 endpoint types (video, image, TTS, STT, embeddings, OCR, batch handles), of which 353 are chat models, and 91 ids appear twice because the same model is served by two endpoints. The generic flavor maps that response straight into the model picker, so today a user scrolls 936 rows — most of which cannot hold a conversation, some listed twice — to find one that can. That is not something the generic flavor can be configured out of; it needs to know the catalog's `type` field. So the flavor is deliberately thin: the same @ai-sdk/openai-compatible transport, a default base URL so a key alone is a complete config (matching aigateway), and one catalog parser. It curates nothing — no hardcoded model list, no reordering — the provider's own order is preserved. The parser fails open in both directions, which is the point of the tests around it. An entry with no type is kept, and if filtering would empty a non-empty catalog the unfiltered list is returned instead. This discriminator has been renamed once already ("chat-completion" -> "openai/chat-completions") and the failure mode is a permanently empty picker that looks like an outage — a longer list is the cheaper failure. Chat only. Image generation is not wired: the catalog types image models separately and their request path was not verified, and shipping a picker entry that 404s is worse than not offering one. Verified against the live service through this code path, not a mock: listModelsForProvider returns 353 de-duplicated ids, testModelConnection succeeds, and generateText completes a two-step tool-calling loop on anthropic/claude-sonnet-4.5. --- .../src/components/model-selector.tsx | 2 + .../components/onboarding/provider-icons.tsx | 12 ++ .../components/settings/providers-section.tsx | 8 +- .../packages/core/src/models/aimlapi.test.ts | 137 ++++++++++++++++++ apps/x/packages/core/src/models/aimlapi.ts | 82 +++++++++++ apps/x/packages/core/src/models/catalog.ts | 8 + apps/x/packages/core/src/models/models.ts | 26 ++++ apps/x/packages/shared/src/models.ts | 2 +- 8 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 apps/x/packages/core/src/models/aimlapi.test.ts create mode 100644 apps/x/packages/core/src/models/aimlapi.ts diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx index a30bbdc6d..c09c866a5 100644 --- a/apps/x/apps/renderer/src/components/model-selector.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.tsx @@ -27,6 +27,8 @@ export const providerDisplayNames: Record = { ollama: 'Ollama', openrouter: 'OpenRouter', aigateway: 'AI Gateway', + // The provider's own name is its domain; keep it verbatim. + aimlapi: 'aimlapi.com', 'openai-compatible': 'OpenAI-Compatible', rowboat: 'Rowboat', // Matches what other subscription clients call this provider; the auth diff --git a/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx index e344e8834..e9800751c 100644 --- a/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx @@ -56,6 +56,18 @@ export function VercelIcon({ className }: IconProps) { ) } +// aimlapi.com's mark: the hexagonal container plus its zig-zag glyph, +// reduced to two currentColor paths so it inherits the sheet's monochrome +// treatment like every other provider icon here. +export function AimlapiIcon({ className }: IconProps) { + return ( + + + + + ) +} + export function GmailIcon({ className }: IconProps) { return ( diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx index 1bab23705..f1c9a6486 100644 --- a/apps/x/apps/renderer/src/components/settings/providers-section.tsx +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -14,6 +14,7 @@ import { useModels } from "@/hooks/use-models" import { useRowboatConfig } from "@/hooks/use-rowboat-config" import { useChatGPT } from "@/hooks/useChatGPT" import { + AimlapiIcon, AnthropicIcon, GenericApiIcon, GoogleIcon, @@ -29,7 +30,7 @@ import { // Providers manage CREDENTIALS only — model choices live in // ModelSelectionSection above this section. -type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" +type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "aimlapi" | "ollama" | "openai-compatible" interface ProviderMeta { id: string @@ -60,6 +61,10 @@ const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; i { flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true }, { flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false }, { flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false }, + // manualModel: the catalog is ~350 chat models deep and its order is the + // provider's, so auto-select (first listed) is close to arbitrary — the + // optional box lets someone name the model they came for at connect time. + { flavor: "aimlapi", name: "aimlapi.com", tagline: "One key, 350+ chat models", icon: AimlapiIcon, needsKey: true, needsEndpoint: false, manualModel: true }, { flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true }, ] @@ -67,6 +72,7 @@ const DEFAULT_BASE_URLS: Partial> = { ollama: "http://localhost:11434", "openai-compatible": "http://localhost:1234/v1", aigateway: "https://ai-gateway.vercel.sh/v1", + aimlapi: "https://api.aimlapi.com/v1", } function flavorMeta(flavor: string) { diff --git a/apps/x/packages/core/src/models/aimlapi.test.ts b/apps/x/packages/core/src/models/aimlapi.test.ts new file mode 100644 index 000000000..ab37ebc44 --- /dev/null +++ b/apps/x/packages/core/src/models/aimlapi.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AIMLAPI_BASE_URL, parseAimlapiChatModelIds } from "./aimlapi.js"; +import { listModelsForProvider } from "./models.js"; + +/** + * The aimlapi.com flavor's whole reason to exist is reading a catalog that + * describes every endpoint the account can reach, not just chat. These tests + * pin the filter, the de-duplication, and — most importantly — the two + * fail-open rules, because the failure this guards against (a renamed type + * string emptying the model picker) is silent and has happened before in + * other clients of this API. + */ + +const CHAT = "openai/chat-completions"; + +/** A catalog body shaped exactly like the live one, in miniature. */ +function catalog(entries: Array>) { + return { object: "list", data: entries }; +} + +describe("parseAimlapiChatModelIds", () => { + it("keeps chat models and drops every other endpoint type", () => { + const ids = parseAimlapiChatModelIds(catalog([ + { id: "openai/gpt-4.1-mini", type: CHAT }, + { id: "google/veo-3", type: "internal/video-generations/submit" }, + { id: "openai/gpt-image-1", type: "openai/image-generations" }, + { id: "anthropic/claude-sonnet-4.5", type: CHAT }, + { id: "openai/text-embedding-3-large", type: "openai/embeddings" }, + { id: "openai/gpt-5-1-codex", type: "openai/responses/submit" }, + ])); + expect(ids).toEqual(["openai/gpt-4.1-mini", "anthropic/claude-sonnet-4.5"]); + }); + + it("de-duplicates ids served by more than one endpoint", () => { + // Live today for 91 ids: the same model is published as both a + // chat-completions and a responses entry. Ids are picker keys. + const ids = parseAimlapiChatModelIds(catalog([ + { id: "openai/gpt-4.1-mini", type: CHAT }, + { id: "openai/gpt-4.1-mini", type: "openai/responses/submit" }, + { id: "openai/gpt-4.1-mini", type: CHAT }, + ])); + expect(ids).toEqual(["openai/gpt-4.1-mini"]); + }); + + it("preserves the provider's own ordering and curates nothing", () => { + const ids = parseAimlapiChatModelIds(catalog([ + { id: "z-last", type: CHAT }, + { id: "a-first", type: CHAT }, + { id: "m-middle", type: CHAT }, + ])); + expect(ids).toEqual(["z-last", "a-first", "m-middle"]); + }); + + it("keeps entries that carry no type at all", () => { + const ids = parseAimlapiChatModelIds(catalog([ + { id: "untyped-model" }, + { id: "typed-chat", type: CHAT }, + ])); + expect(ids).toEqual(["untyped-model", "typed-chat"]); + }); + + it("falls back to the unfiltered list when the type vocabulary changes", () => { + // The discriminator has been renamed before. A picker showing a + // longer list is recoverable; one showing nothing looks like an + // outage and is what this branch exists to prevent. + const ids = parseAimlapiChatModelIds(catalog([ + { id: "some/model", type: "chat-completion" }, + { id: "other/model", type: "chat-completion" }, + ])); + expect(ids).toEqual(["some/model", "other/model"]); + }); + + it("returns nothing for a bare array or a missing envelope", () => { + // The response is { object, data: [...] }, never a bare array — + // assuming otherwise is how this catalog has broken clients before. + expect(parseAimlapiChatModelIds([{ id: "x", type: CHAT }])).toEqual([]); + expect(parseAimlapiChatModelIds({})).toEqual([]); + expect(parseAimlapiChatModelIds(null)).toEqual([]); + }); + + it("skips entries whose id is missing or not a string", () => { + const ids = parseAimlapiChatModelIds(catalog([ + { type: CHAT }, + { id: "", type: CHAT }, + { id: 42, type: CHAT }, + { id: "good/model", type: CHAT }, + ])); + expect(ids).toEqual(["good/model"]); + }); +}); + +describe("listModelsForProvider (aimlapi)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubFetch(body: unknown) { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => body, + } as unknown as Response)); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("lists the hosted catalog with the key, and filters it", async () => { + const fetchMock = stubFetch(catalog([ + { id: "openai/gpt-4.1-mini", type: CHAT }, + { id: "openai/gpt-4.1-mini", type: "openai/responses/submit" }, + { id: "google/veo-3", type: "internal/video-generations/submit" }, + ])); + const models = await listModelsForProvider({ flavor: "aimlapi", apiKey: "k" }); + expect(models).toEqual(["openai/gpt-4.1-mini"]); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${AIMLAPI_BASE_URL}/models`); + expect((init.headers as Record).Authorization).toBe("Bearer k"); + }); + + it("uses the hosted default when the entry carries no base URL", async () => { + const fetchMock = stubFetch(catalog([])); + await listModelsForProvider({ flavor: "aimlapi", apiKey: "k" }); + expect((fetchMock.mock.calls[0] as unknown as [string])[0]).toBe(`${AIMLAPI_BASE_URL}/models`); + }); + + it("honours an overridden base URL and trims its trailing slash", async () => { + const fetchMock = stubFetch(catalog([])); + await listModelsForProvider({ flavor: "aimlapi", apiKey: "k", baseURL: "https://proxy.example/v1/" }); + expect((fetchMock.mock.calls[0] as unknown as [string])[0]).toBe("https://proxy.example/v1/models"); + }); + + it("omits the Authorization header when no key is configured", async () => { + const fetchMock = stubFetch(catalog([])); + await listModelsForProvider({ flavor: "aimlapi" }); + const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(init.headers).not.toHaveProperty("Authorization"); + }); +}); diff --git a/apps/x/packages/core/src/models/aimlapi.ts b/apps/x/packages/core/src/models/aimlapi.ts new file mode 100644 index 000000000..4880210a3 --- /dev/null +++ b/apps/x/packages/core/src/models/aimlapi.ts @@ -0,0 +1,82 @@ +/** + * AI/ML API (aimlapi.com) — an OpenAI-compatible aggregator, addressed the + * same way OpenRouter and the Vercel AI Gateway are: one key, one base URL, + * many vendors' models behind vendor-prefixed ids ("openai/gpt-4.1", + * "anthropic/claude-sonnet-4.5", "google/gemini-2.5-flash"). + * + * It reaches Rowboat through @ai-sdk/openai-compatible like the generic + * flavor does — the only thing this module adds is knowing how to read the + * provider's catalog, which is what the generic flavor cannot do. + * + * Why its own flavor rather than "openai-compatible" with a pasted URL: + * GET /v1/models answers with EVERY endpoint the account can reach, not just + * chat. Today that is 936 entries across 15 endpoint types — 353 chat models + * plus video, image, TTS, STT, embeddings, OCR and Anthropic batch handles — + * and 91 ids appear more than once because the same model is served by two + * endpoints (e.g. "openai/gpt-4.1-mini" as both chat-completions and + * responses). The generic flavor maps that straight to the picker, so a user + * scrolls 936 rows, most of which cannot hold a conversation, some listed + * twice. Filtering by the catalog's own `type` discriminator is a few lines + * here and cannot be expressed as configuration there. + */ + +/** Default endpoint. Overridable per provider entry (staging, a proxy). */ +export const AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1"; + +/** + * The catalog's endpoint-type discriminator for conversational models. + * + * This string is load-bearing and has changed before ("chat-completion" → + * "openai/chat-completions"), which is exactly how a sibling integration + * ended up with a permanently empty model dropdown. Hence the fail-open rule + * in parseAimlapiChatModelIds below: an unrecognised vocabulary degrades to + * the unfiltered list, never to nothing. + */ +const CHAT_COMPLETIONS_TYPE = "openai/chat-completions"; + +interface CatalogEntry { + id?: unknown; + type?: unknown; +} + +/** + * Chat-capable model ids from a GET /v1/models body, in catalog order. + * + * The response is an OpenAI-shaped envelope — `{ "object": "list", "data": + * [...] }`, not a bare array — and each entry carries `type`. Entries are + * kept when that type is the chat one, de-duplicated (ids are picker keys), + * and returned in the order the provider sent them; nothing here reorders or + * curates the catalog. + * + * Fails OPEN, in two steps, because a dark picker is worse than a noisy one: + * - an entry with no `type` at all is kept (an older or trimmed response); + * - if the filter would empty a non-empty catalog — every entry typed, none + * of them chat — the unfiltered id list is returned instead, so a renamed + * type string costs users a longer list rather than the whole provider. + */ +export function parseAimlapiChatModelIds(payload: unknown): string[] { + const data = (payload as { data?: unknown } | null)?.data; + if (!Array.isArray(data)) return []; + + const all: string[] = []; + const chat: string[] = []; + const seenAll = new Set(); + const seenChat = new Set(); + + for (const raw of data as CatalogEntry[]) { + const id = raw?.id; + if (typeof id !== "string" || id.length === 0) continue; + if (!seenAll.has(id)) { + seenAll.add(id); + all.push(id); + } + const type = raw?.type; + const isChat = type === CHAT_COMPLETIONS_TYPE || type === undefined || type === null; + if (isChat && !seenChat.has(id)) { + seenChat.add(id); + chat.push(id); + } + } + + return chat.length > 0 ? chat : all; +} diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts index b77518fa9..2b485c72d 100644 --- a/apps/x/packages/core/src/models/catalog.ts +++ b/apps/x/packages/core/src/models/catalog.ts @@ -9,6 +9,7 @@ import { listCodexModels } from "./codex.js"; import { listImageModelsForProvider, listModelsForProvider } from "./models.js"; import { getImageModelIds, listOnboardingModels } from "./models-dev.js"; import { getDefaultModelAndProvider } from "./defaults.js"; +import { AIMLAPI_BASE_URL } from "./aimlapi.js"; /** * The unified model catalog: one function that answers "which providers are @@ -61,6 +62,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { google: "Gemini", openrouter: "OpenRouter", aigateway: "AI Gateway", + aimlapi: "aimlapi.com", ollama: "Ollama", "openai-compatible": "OpenAI-Compatible", }; @@ -82,6 +84,9 @@ const MODELS_DEV_FLAVORS = new Set(["openai", "anthropic", "google"]); // listModelsForProvider builds aigateway's URL from baseURL; apply the // service default here so a keyed-but-URL-less config still lists. const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"; +// Same for aimlapi: the flavor has one hosted endpoint, so a config that +// carries only a key is complete. createProvider applies the same default, +// and both read it from models/aimlapi.ts. // Successful lists are cached until the provider's credentials change or an // explicit refresh; failures retry after a short TTL so a temporarily-down @@ -151,6 +156,9 @@ async function discoverProviders(): Promise { if (config.flavor === "aigateway" && !config.baseURL) { config.baseURL = AIGATEWAY_DEFAULT_BASE_URL; } + if (config.flavor === "aimlapi" && !config.baseURL) { + config.baseURL = AIMLAPI_BASE_URL; + } discovered.push({ id, flavor: entry.flavor, config }); } diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 1f63091b8..52e2cff2c 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -12,6 +12,7 @@ import { getGatewayProvider } from "./gateway.js"; import { getCodexProvider } from "./codex.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js"; import { getChatModelIds } from "./models-dev.js"; +import { AIMLAPI_BASE_URL, parseAimlapiChatModelIds } from "./aimlapi.js"; import { withUseCase } from "../analytics/use_case.js"; import { applyLocalModelSettings, @@ -74,6 +75,16 @@ export function createProvider(config: z.infer): ProviderV4 { baseURL: baseURL || "", headers, }); + case "aimlapi": + // Same transport as openai-compatible; the flavor exists for the + // default endpoint, the catalog filtering in listModelsForProvider, + // and its own analytics/display identity. + return createOpenAICompatible({ + name: "aimlapi", + apiKey, + baseURL: baseURL || AIMLAPI_BASE_URL, + headers, + }); case "openrouter": return createOpenRouter({ apiKey, @@ -285,6 +296,17 @@ export async function listModelsForProvider( url = `${(baseURL ?? "").replace(/\/$/, "")}/models`; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; break; + case "aimlapi": + // The catalog is public — it answers 200 with no key, a valid + // key, or a garbage one — so listing here can no more tell a + // bad key from a good one than OpenRouter's public catalog + // can. The key still goes on the request (an account-scoped + // catalog may arrive later); what actually validates the + // credential is testModelConnection's generateText call, + // which the connect flow always runs after this. + url = `${(baseURL ?? AIMLAPI_BASE_URL).replace(/\/$/, "")}/models`; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + break; default: throw new Error(`Unsupported provider flavor: ${flavor}`); } @@ -304,6 +326,10 @@ export async function listModelsForProvider( } else if (flavor === "ollama") { // { models: [{ name: "llama3:latest" }] } ids = (data.models ?? []).map((m: { name: string }) => m.name); + } else if (flavor === "aimlapi") { + // Same OpenAI envelope, but every endpoint type shares it — keep + // the chat ones and drop the duplicates. See aimlapi.ts. + ids = parseAimlapiChatModelIds(data); } else { // OpenAI-shaped: { data: [{ id: "..." }] } ids = (data.data ?? []).map((m: { id: string }) => m.id); diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index d7a4cd9c5..2ca7057b9 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -16,7 +16,7 @@ export const LlmProvider = z.object({ // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via // "Sign in with ChatGPT") are credential-less flavors: they never appear // in models.json's providers map — auth lives in their own token stores. - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat", "codex"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "aimlapi", "ollama", "openai-compatible", "rowboat", "codex"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), From 598895e8b796d948d41b4dfd6c909c0d0247804d Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 3 Sep 2026 14:25:46 +0500 Subject: [PATCH 2/4] feat(x): attribute Rowboat's traffic on the aimlapi.com provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LlmProvider already carries a per-provider `headers` map that createProvider threads into every request, so this is a default for that map rather than new machinery. Two of the three headers are OpenRouter's convention, which this provider also reads: HTTP-Referer and X-Title name the CALLING app — Rowboat and its repo, not aimlapi.com — and X-AIMLAPI-Source is the provider's own channel tag. Scoped by ORIGIN, not by flavor. A provider entry may be pointed at a proxy or a compatible peer, and attribution must never ride along to someone else's service; pointing this flavor at another host sends the user's own headers and nothing more. The headers merge under whatever the user configured, so a user's key always wins, and the returned map is built fresh per provider so the shared default can never be edited in place. No partner id is included. None has been issued for Rowboat, and an invented one is worse than none: the receiving service DROPS a malformed id instead of rejecting the request, so the call succeeds and the attribution silently goes nowhere. The constant is empty, the header is omitted entirely while it is, and a test asserts the id is either empty or well-formed — that assertion is the only thing that would catch a typo the day one is filled in. Verified on the wire, not only in tests: a real chat completion against api.aimlapi.com carries http-referer / x-title / x-aimlapi-source; the same provider pointed at another host carries none of them; and the generic openai-compatible flavor aimed at the same URL is unchanged. --- .../packages/core/src/models/aimlapi.test.ts | 57 ++++++++++++++++++- apps/x/packages/core/src/models/aimlapi.ts | 57 +++++++++++++++++++ apps/x/packages/core/src/models/models.ts | 4 +- 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/apps/x/packages/core/src/models/aimlapi.test.ts b/apps/x/packages/core/src/models/aimlapi.test.ts index ab37ebc44..d4ef8f442 100644 --- a/apps/x/packages/core/src/models/aimlapi.test.ts +++ b/apps/x/packages/core/src/models/aimlapi.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { AIMLAPI_BASE_URL, parseAimlapiChatModelIds } from "./aimlapi.js"; +import { AIMLAPI_BASE_URL, AIMLAPI_PARTNER_ID, aimlapiRequestHeaders, parseAimlapiChatModelIds } from "./aimlapi.js"; import { listModelsForProvider } from "./models.js"; /** @@ -135,3 +135,58 @@ describe("listModelsForProvider (aimlapi)", () => { expect(init.headers).not.toHaveProperty("Authorization"); }); }); + +describe("aimlapiRequestHeaders", () => { + it("sends the calling app's identity, not the provider's", () => { + // HTTP-Referer / X-Title are OpenRouter's convention and name the + // app making the call — Rowboat. Getting this backwards makes the + // analytics on the other side useless. + const headers = aimlapiRequestHeaders({}); + expect(headers).toMatchObject({ + "HTTP-Referer": "https://github.com/rowboatlabs/rowboat", + "X-Title": "Rowboat", + "X-AIMLAPI-Source": "agent/rowboat", + }); + }); + + it("keeps the partner id well-formed, or absent", () => { + // A malformed id is dropped by the receiving service without an + // error, so the request succeeds and the attribution vanishes. This + // assertion is the only thing that would catch a typo in it. + expect(AIMLAPI_PARTNER_ID === "" || /^part_[A-Za-z0-9]{1,64}$/.test(AIMLAPI_PARTNER_ID)).toBe(true); + const headers = aimlapiRequestHeaders({}) ?? {}; + if (AIMLAPI_PARTNER_ID === "") { + expect(headers).not.toHaveProperty("X-AIMLAPI-Partner-ID"); + } else { + expect(headers["X-AIMLAPI-Partner-ID"]).toBe(AIMLAPI_PARTNER_ID); + } + }); + + it("never attaches attribution to another origin", () => { + // A provider entry can be pointed at a proxy or a compatible peer. + // Attribution must not ride to someone else's service. + const own = { "X-Custom": "mine" }; + expect(aimlapiRequestHeaders({ baseURL: "https://proxy.example/v1", headers: own })).toEqual(own); + expect(aimlapiRequestHeaders({ baseURL: "https://proxy.example/v1" })).toBeUndefined(); + expect(aimlapiRequestHeaders({ baseURL: "not a url" })).toBeUndefined(); + }); + + it("still attributes a path override on our own origin", () => { + expect(aimlapiRequestHeaders({ baseURL: "https://api.aimlapi.com/v2" })) + .toHaveProperty("X-AIMLAPI-Source", "agent/rowboat"); + }); + + it("merges rather than assigns — a user's header wins", () => { + const headers = aimlapiRequestHeaders({ headers: { "X-Title": "My Fork", "X-Extra": "1" } }); + expect(headers).toMatchObject({ "X-Title": "My Fork", "X-Extra": "1" }); + expect(headers).toHaveProperty("X-AIMLAPI-Source", "agent/rowboat"); + }); + + it("builds a fresh object per call and never mutates the shared defaults", () => { + const first = aimlapiRequestHeaders({}) as Record; + first["X-Title"] = "mutated"; + const second = aimlapiRequestHeaders({}) as Record; + expect(second).not.toBe(first); + expect(second["X-Title"]).toBe("Rowboat"); + }); +}); diff --git a/apps/x/packages/core/src/models/aimlapi.ts b/apps/x/packages/core/src/models/aimlapi.ts index 4880210a3..95ed5991b 100644 --- a/apps/x/packages/core/src/models/aimlapi.ts +++ b/apps/x/packages/core/src/models/aimlapi.ts @@ -80,3 +80,60 @@ export function parseAimlapiChatModelIds(payload: unknown): string[] { return chat.length > 0 ? chat : all; } + +/** + * Attribution headers, the same pair of conventions this provider's peers + * already use: HTTP-Referer / X-Title are OpenRouter's (they name the + * CALLING app — Rowboat — not the provider), and X-AIMLAPI-Source is the + * provider's own channel tag. + * + * Frozen and never sent directly: aimlapiRequestHeaders builds a fresh + * object per provider so nothing downstream can edit the shared constant. + */ +const ATTRIBUTION_HEADERS: Readonly> = Object.freeze({ + "HTTP-Referer": "https://github.com/rowboatlabs/rowboat", + "X-Title": "Rowboat", + "X-AIMLAPI-Source": "agent/rowboat", +}); + +/** + * Partner id, format ^part_[A-Za-z0-9]{1,64}$ — asserted in aimlapi.test.ts, + * because a malformed one is DROPPED by the receiving service rather than + * rejected: the request succeeds and the attribution silently goes nowhere. + * + * Empty on purpose. No id has been issued for Rowboat, and a made-up value + * is worse than none. When one is issued this constant is the only edit; + * while it is empty the header is not sent at all. + */ +export const AIMLAPI_PARTNER_ID: string = ""; + +/** Attribution rides only to this origin — never to a proxy or a peer. */ +const ATTRIBUTION_ORIGIN = "https://api.aimlapi.com"; + +function isAimlapiOrigin(baseURL: string | undefined): boolean { + try { + return new URL(baseURL || AIMLAPI_BASE_URL).origin === ATTRIBUTION_ORIGIN; + } catch { + // An unparseable override is not our origin. + return false; + } +} + +/** + * The headers an aimlapi provider sends: the user's own, plus attribution + * when — and only when — the request is actually going to aimlapi.com. A + * provider entry may point at a proxy or a compatible peer; attribution must + * not ride along to someone else's service, so the origin is checked rather + * than trusting the flavor alone. + * + * Merges, never assigns: a user's configured header wins on a key clash, and + * the returned object is new every call. + */ +export function aimlapiRequestHeaders( + config: { baseURL?: string; headers?: Record }, +): Record | undefined { + if (!isAimlapiOrigin(config.baseURL)) return config.headers; + const headers: Record = { ...ATTRIBUTION_HEADERS }; + if (AIMLAPI_PARTNER_ID) headers["X-AIMLAPI-Partner-ID"] = AIMLAPI_PARTNER_ID; + return { ...headers, ...(config.headers ?? {}) }; +} diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 52e2cff2c..c20d7778e 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -12,7 +12,7 @@ import { getGatewayProvider } from "./gateway.js"; import { getCodexProvider } from "./codex.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js"; import { getChatModelIds } from "./models-dev.js"; -import { AIMLAPI_BASE_URL, parseAimlapiChatModelIds } from "./aimlapi.js"; +import { AIMLAPI_BASE_URL, aimlapiRequestHeaders, parseAimlapiChatModelIds } from "./aimlapi.js"; import { withUseCase } from "../analytics/use_case.js"; import { applyLocalModelSettings, @@ -83,7 +83,7 @@ export function createProvider(config: z.infer): ProviderV4 { name: "aimlapi", apiKey, baseURL: baseURL || AIMLAPI_BASE_URL, - headers, + headers: aimlapiRequestHeaders(config), }); case "openrouter": return createOpenRouter({ From 3e745887f3bd81f3a20950ac952ce7d51dfdb9b1 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Thu, 3 Sep 2026 14:27:12 +0500 Subject: [PATCH 3/4] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the aimlapi.com tile to the top of the add-provider list. That array is hand-ordered and its order is exactly what the dialog renders, so this is a placement preference and nothing else — no behaviour, no defaults, no model selection changes with it. It is isolated in this commit precisely so it can be dropped with a single revert before any upstream proposal. Choosing the order of another project's provider list is the maintainers' call, not a contributor's. Rowboat has no "recommended" or "featured" badge for providers, and none was invented for this. The only recommendation concept in the tree picks a recommended MODEL per flavor and is served by Rowboat's own backend, so it is not something a code change can set. --- .../src/components/settings/providers-section.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx index f1c9a6486..0d7db0b4c 100644 --- a/apps/x/apps/renderer/src/components/settings/providers-section.tsx +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -55,16 +55,16 @@ const TASK_LABELS: Record = { } const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; icon: React.ElementType; needsKey: boolean; needsEndpoint: boolean; optionalKey?: boolean; manualModel?: boolean }> = [ + // manualModel: the catalog is ~350 chat models deep and its order is the + // provider's, so auto-select (first listed) is close to arbitrary — the + // optional box lets someone name the model they came for at connect time. + { flavor: "aimlapi", name: "aimlapi.com", tagline: "One key, 350+ chat models", icon: AimlapiIcon, needsKey: true, needsEndpoint: false, manualModel: true }, { flavor: "openai", name: "OpenAI", tagline: "GPT models", icon: OpenAIIcon, needsKey: true, needsEndpoint: false }, { flavor: "anthropic", name: "Anthropic", tagline: "Claude models", icon: AnthropicIcon, needsKey: true, needsEndpoint: false }, { flavor: "google", name: "Gemini", tagline: "Google AI Studio", icon: GoogleIcon, needsKey: true, needsEndpoint: false }, { flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true }, { flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false }, { flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false }, - // manualModel: the catalog is ~350 chat models deep and its order is the - // provider's, so auto-select (first listed) is close to arbitrary — the - // optional box lets someone name the model they came for at connect time. - { flavor: "aimlapi", name: "aimlapi.com", tagline: "One key, 350+ chat models", icon: AimlapiIcon, needsKey: true, needsEndpoint: false, manualModel: true }, { flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true }, ] From b0e72481b4903b2f6ef70f196a5592dd4ed8d47a Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:20:04 +0500 Subject: [PATCH 4/4] fix(aimlapi): use the registered partner id The placeholder export const AIMLAPI_PARTNER_ID: string = ""; was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is export const AIMLAPI_PARTNER_ID: string = "part_VGDbk3ZJHZ1bi3eoaLNwToNC";. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- apps/x/packages/core/src/models/aimlapi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/models/aimlapi.ts b/apps/x/packages/core/src/models/aimlapi.ts index 95ed5991b..9c1535b96 100644 --- a/apps/x/packages/core/src/models/aimlapi.ts +++ b/apps/x/packages/core/src/models/aimlapi.ts @@ -105,7 +105,7 @@ const ATTRIBUTION_HEADERS: Readonly> = Object.freeze({ * is worse than none. When one is issued this constant is the only edit; * while it is empty the header is not sent at all. */ -export const AIMLAPI_PARTNER_ID: string = ""; +export const AIMLAPI_PARTNER_ID: string = "part_VGDbk3ZJHZ1bi3eoaLNwToNC"; /** Attribution rides only to this origin — never to a proxy or a peer. */ const ATTRIBUTION_ORIGIN = "https://api.aimlapi.com";