Skip to content

feat(x): add aimlapi.com as a named model provider - #1

Open
Lookoff-AIMLAPI wants to merge 4 commits into
mainfrom
feat/aimlapi-provider
Open

feat(x): add aimlapi.com as a named model provider#1
Lookoff-AIMLAPI wants to merge 4 commits into
mainfrom
feat/aimlapi-provider

Conversation

@Lookoff-AIMLAPI

Copy link
Copy Markdown
Member

Adds aimlapi.com (AI/ML API) as a named model provider in apps/x, alongside OpenRouter and the Vercel AI Gateway.

Why, given it already "works"

It does — and that was verified by running it, not by reading. Configuring the existing openai-compatible flavor with https://api.aimlapi.com/v1 and driving Rowboat's own listModelsForProvidertestModelConnectiongenerateText path produced a real completion and a real two-step tool-calling loop on the first try. There is no allowlist, no hardcoded model list, and no base-URL assumption in the way. Nothing was broken and nothing needed a bugfix.

What the generic path cannot do is read the catalog. GET /v1/models on this provider answers with every endpoint the account can reach, not just chat:

entries returned 936
distinct endpoint types 15 (chat, video, image, TTS, STT, embeddings, OCR, batch handles, …)
chat models among them 353
ids published more than once 91 (same model served by two endpoints)

The generic flavor maps that body straight into the model picker, so today a user scrolls 936 rows — most of which cannot hold a conversation, some of them listed twice — to reach one that can. That is not configuration; reading the catalog's type field is code. With the named flavor the same account lists 353 de-duplicated chat models.

The rest is what a named provider gets for free: the endpoint prefilled so a key alone is a complete config (the same treatment aigateway has), its own tile and display name, and its own analytics flavor.

What it is not

  • No curated model list. Nothing here hardcodes model ids, and the provider's own ordering is preserved verbatim. The one filter is on endpoint type.
  • Chat only. The catalog types image models separately, but their request path was not verified from here, so no image-model listing is wired. A picker entry that 404s is worse than no entry.
  • No reasoning-effort mapping. Like openai-compatible, there is no parameter that is safe across ~350 models from a dozen vendors, so effort sends nothing rather than guessing.

Failing open, on purpose

parseAimlapiChatModelIds keeps untyped entries, and if filtering would empty a non-empty catalog it returns the unfiltered list instead. The discriminator this filter reads has been renamed once already (chat-completionopenai/chat-completions), and the failure mode is a permanently empty model picker that looks like an outage. A longer list is the cheaper failure. Both branches are covered by tests.

Attribution (second commit, separable)

LlmProvider already carries a per-provider headers map that createProvider threads into every request, so this is a default for an existing mechanism rather than new machinery. It sends HTTP-Referer and X-Title — OpenRouter's convention, naming Rowboat as the calling app, not the provider — plus X-AIMLAPI-Source: agent/rowboat.

Three properties worth reviewing:

  • Scoped by origin, not by flavor. A provider entry can be pointed at a proxy or a compatible peer; attribution must not ride to someone else's service. Point this flavor elsewhere and it sends the user's own headers and nothing more.
  • Merged, never assigned, and built fresh per provider — a user's configured header wins on a clash, and the shared default cannot be edited in place.
  • No partner id. None has been issued for Rowboat, and an invented one is worse than none: a malformed id is dropped by the receiving service rather than rejected, 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 it is either empty or matches ^part_[A-Za-z0-9]{1,64}$ — that assertion is the only thing that would catch a typo the day one is filled in.

Placement — fork-only, drop before any upstream proposal

The last commit, chore(aimlapi): fork-only placement — do not send upstream, moves the tile to the top of the hand-ordered add-provider list. It is isolated so a single revert removes it; ordering another project's provider list is the maintainers' call. Rowboat has no "recommended"/"featured" badge concept for providers and none was invented — the only recommendation mechanism picks a recommended model per flavor and is served by Rowboat's backend, so no code change can set it.

Verification

Baseline captured on a pristine main (88a58412) before any change, then repeated after each commit. pnpm install --frozen-lockfile in apps/harbor then apps/x, then npm test, npm run typecheck and npm run lint in apps/x — the same three gates CI runs. Exit codes read bare, not through a pipe.

suite baseline after
packages/shared 304 passed / 18 files 304 passed / 18 files
packages/core 826 passed / 72 files 843 passed / 73 files
apps/server 27 passed / 4 files 27 passed / 4 files
apps/renderer 373 passed / 37 files 373 passed / 37 files
total 1530 passed, 0 failed 1547 passed, 0 failed

npm run typecheck and npm run lint: clean before and after. The 17 new tests are all in aimlapi.test.ts.

The typecheck gate earned its place here — the first draft of the attribution helper emitted a TS2322 that tsc reported while still writing output, so the compiled dist ran fine and only the bare exit code showed the failure.

Live calls, through this code path

Not curl and not a mock: createLanguageModel({ flavor: "aimlapi", apiKey }) driving the AI SDK, on the built packages/core.

== listModelsForProvider(aimlapi) ==
   returned 353 ids; first 5: [ 'openai/gpt-3.5-turbo', 'openai/gpt-3.5-turbo-0125',
     'openai/gpt-3.5-turbo-1106', 'openai/gpt-4', 'openai/gpt-4.1-mini' ]
   contains anthropic/claude-sonnet-4.5 ? true
== testModelConnection (what the settings UI calls) ==
   {"success":true,"capabilities":{}}
== generateText through createLanguageModel ==
   text: "rowboat-aimlapi-ok"
   finishReason: stop  usage: {"inputTokens":19,"outputTokens":11,"totalTokens":30}
   response.modelId: anthropic/claude-sonnet-4.5
== tool calling (two turns: with tools, then without) ==
   steps: 2
   toolCalls: [{"name":"get_weather","input":{"city":"Paris"}}]
   final text: "The weather in Paris is currently: - Temperature: 17°C - Sky conditions: Clear"
   turn-2 without tools: "OK."

The second turn is deliberate. This API rejects tools: null with a 400 on its stricter models, so a client that clears tools between turns by nulling the field succeeds on turn 1 and fails on turn 2 of every agent loop. The AI SDK omits the key instead of nulling it, so Rowboat is not exposed to that — confirmed rather than assumed.

Attribution was checked on the wire by recording outgoing request headers around a real completion:

A. aimlapi flavor, default base URL          -> https://api.aimlapi.com/v1/chat/completions
      http-referer: https://github.com/rowboatlabs/rowboat
      x-title: Rowboat
      x-aimlapi-source: agent/rowboat            (HTTP 200, text "OK.")
B. aimlapi flavor pointed at another host    -> no attribution headers at all
C. openai-compatible flavor at the same URL  -> unchanged, no attribution headers

Known rough edges

  • A fresh connect seeds list[0] as the assistant model. That is selectInitialModel's existing rule and it applies equally to OpenRouter and AI Gateway, whose catalog order is just as arbitrary. For this provider list[0] is openai/gpt-3.5-turbo, which is a poor first impression for an agent. Rather than hardcode a favourite, the tile carries manualModel so the optional model box lets someone name the model they came for at connect time; the durable fix belongs in Rowboat's per-flavor model recommendations, which are backend-served.
  • completion_tokens under-reports on some models on this provider — google/gemini-2.5-flash returned prompt 12 + completion 3 against a total of 86, with 71 reasoning tokens uncounted. Anything metering spend from completion tokens alone will under-count on those models.
  • max_tokens does not reliably bound output on every model here; some return several times the cap with finish_reason: "stop". It should not be presented as a cost bound.
  • Prompt caching is a no-op through this flavor. applyPromptCaching matches Anthropic models by id, so anthropic/* ids routed here get providerOptions.anthropic breakpoints that the openai-compatible transport does not forward. Harmless — the request is unchanged — but the caching those breakpoints are meant to buy does not happen. Left alone deliberately; wiring it would mean a provider-specific transport, which is a much larger change than this one.

aimlapi and others added 4 commits September 3, 2026 14:21
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant