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..0d7db0b4c 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 @@ -54,6 +55,10 @@ 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 }, @@ -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..d4ef8f442 --- /dev/null +++ b/apps/x/packages/core/src/models/aimlapi.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AIMLAPI_BASE_URL, AIMLAPI_PARTNER_ID, aimlapiRequestHeaders, 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"); + }); +}); + +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 new file mode 100644 index 000000000..9c1535b96 --- /dev/null +++ b/apps/x/packages/core/src/models/aimlapi.ts @@ -0,0 +1,139 @@ +/** + * 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; +} + +/** + * 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 = "part_VGDbk3ZJHZ1bi3eoaLNwToNC"; + +/** 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/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..c20d7778e 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, aimlapiRequestHeaders, 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: aimlapiRequestHeaders(config), + }); 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(),