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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/x/apps/renderer/src/components/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const providerDisplayNames: Record<string, string> = {
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
Expand Down
12 changes: 12 additions & 0 deletions apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg viewBox="0 0 37 33" fill="currentColor" className={cn("size-5", className)}>
<path d="M35.7 15.056a3.9 3.9 0 0 1 0 3.751l-6.949 12.037a3.75 3.75 0 0 1-3.249 1.876H11.604a3.75 3.75 0 0 1-3.249-1.876L1.406 18.807a3.9 3.9 0 0 1 0-3.751L8.355 3.02a3.75 3.75 0 0 1 3.249-1.876h13.898a3.75 3.75 0 0 1 3.249 1.876L35.7 15.056Z" opacity="0.35" />
<path d="M10.926 22.232a1.28 1.28 0 0 1-.746-.253 1.29 1.29 0 0 1-.224-1.718l4.889-6.355a1.28 1.28 0 0 1 1.83-.216l4.606 3.619 4.03-5.2a1.29 1.29 0 0 1 1.719-.219 1.29 1.29 0 0 1 .219 1.718l-4.786 6.175a1.28 1.28 0 0 1-1.808.248l-4.603-3.617-4.135 5.373c-.242.314-.604.478-.971.478Z" />
</svg>
)
}

export function GmailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -54,6 +55,10 @@ const TASK_LABELS: Record<string, string> = {
}

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 },
Expand All @@ -67,6 +72,7 @@ const DEFAULT_BASE_URLS: Partial<Record<ByokFlavor, string>> = {
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) {
Expand Down
192 changes: 192 additions & 0 deletions apps/x/packages/core/src/models/aimlapi.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) {
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<string, string>).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<string, string>;
first["X-Title"] = "mutated";
const second = aimlapiRequestHeaders({}) as Record<string, string>;
expect(second).not.toBe(first);
expect(second["X-Title"]).toBe("Rowboat");
});
});
139 changes: 139 additions & 0 deletions apps/x/packages/core/src/models/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const seenChat = new Set<string>();

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<Record<string, string>> = 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<string, string> },
): Record<string, string> | undefined {
if (!isAimlapiOrigin(config.baseURL)) return config.headers;
const headers: Record<string, string> = { ...ATTRIBUTION_HEADERS };
if (AIMLAPI_PARTNER_ID) headers["X-AIMLAPI-Partner-ID"] = AIMLAPI_PARTNER_ID;
return { ...headers, ...(config.headers ?? {}) };
}
Loading