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: 1 addition & 1 deletion docs/reference/PROVIDER_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway |
| `agnes` | `agnes` | Agnes AI | API key, video | [link](https://agnes-ai.com) | Get API key at agnes-ai.com |
| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required |
| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. |
| `aimlapi` | `aiml` | aimlapi.com | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. |
| `ainative` | `ainative` | AINative Studio | API key | [link](https://ainative.studio) | Create a free API key at ainative.studio (no card), then paste it here as a Bearer token. |
| `aion` | `aion` | Aion Labs | API key | [link](https://www.aionlabs.ai) | Create a free API key at aionlabs.ai (no card), then paste it here as a Bearer token. |
| `alibaba` | `ali` | Alibaba Cloud Model Studio | API key | [link](https://bailian.console.alibabacloud.com/) | — |
Expand Down
48 changes: 41 additions & 7 deletions open-sse/config/providers/registry/aimlapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,48 @@ export const aimlapiProvider: RegistryEntry = {
baseUrl: "https://api.aimlapi.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
// $0.025/day free credits — 200+ models via single aggregator endpoint
// Attribution, same mechanism the openrouter/orcarouter/cline entries use:
// BaseExecutor.buildHeadersPreamble() spreads config.headers into a fresh
// object per request, so these are scoped to this provider's own dispatch and
// cannot ride a request to another upstream, and the constant below is never
// mutated. HTTP-Referer/X-Title follow the OpenRouter convention and identify
// the CALLING app (OmniRoute), not the upstream gateway.
headers: {
"HTTP-Referer": "https://github.com/diegosouzapw/OmniRoute",
"X-Title": "OmniRoute",
"X-AIMLAPI-Source": "agent/omniroute",
"X-AIMLAPI-Partner-ID": "part_T2iNtMuQ3JBmEPwyOKCLOxaP",
},
// Static fallback ONLY — the live catalog (353 chat models) is discovered via
// PROVIDER_MODELS_CONFIG.aimlapi and supersedes this list whenever the fetch
// succeeds, so these entries are what a user sees when discovery is down.
//
// Each id was verified twice on 2026-09-03: present on the catalog's
// `openai/chat-completions` surface (as an id or an alias) AND answering 200 to
// a real POST /v1/chat/completions. Catalog membership alone is not enough —
// `llama-3.3-70b-versatile` is listed as a chat model, advertises tools and
// structured output, and still 404s "model does not exist" on inference.
//
// All six are the UNPREFIXED spelling, deliberately. A `vendor/model` id in
// this registry is matched as an exact model id by parseModel(), which then
// reports provider = null: seeding "anthropic/claude-sonnet-4-6" here makes
// that string stop resolving to the anthropic provider everywhere in the app,
// collapsing its context window from 1M to the 128k default and dropping the
// provider prefix that #8716 exists to preserve. The previous seed was right
// about this, and the bare aliases carry no such prefix.
//
// Anthropic ids use the DOTTED spelling: the catalog carries both
// `claude-sonnet-4-6` and `claude-sonnet-4.6` as separate entries, and the
// dashed one advertises only `streaming` in `capabilities` while the dotted one
// advertises tools, vision, reasoning and structured output. Same for
// claude-opus-4.7 / 4.8.
models: [
{ id: "gpt-4o", name: "GPT-4o (via AI/ML API)" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet (via AI/ML API)" },
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro (via AI/ML API)" },
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", name: "Llama 3.1 70B (via AI/ML API)" },
{ id: "deepseek-chat", name: "DeepSeek Chat (via AI/ML API)" },
{ id: "mistral-large-latest", name: "Mistral Large (via AI/ML API)" },
{ id: "gpt-5", name: "GPT-5 (via aimlapi.com)" },
{ id: "claude-sonnet-4.6", name: "Claude 4.6 Sonnet (via aimlapi.com)" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (via aimlapi.com)" },
{ id: "glm-5", name: "GLM-5 (via aimlapi.com)" },
{ id: "deepseek-chat", name: "DeepSeek V3 (via aimlapi.com)" },
{ id: "mistral-large", name: "Mistral Large (via aimlapi.com)" },
],
passthroughModels: true,
};
51 changes: 51 additions & 0 deletions open-sse/translator/paramSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
// - `provider` (optional) limits the rule to a single provider id.
// - `match` is a RegExp tested against the model id OR a predicate (model -> boolean).
// - `drop` is the list of param keys to remove when the rule fires.
// - `dropIfNull` removes a key only when its value is literally `null`, leaving
// real values untouched. For upstreams that reject `null` on an optional
// param instead of treating it as "unset" — the OpenAI SDKs serialise an
// unset optional as `null`, so such a param arrives on ordinary requests.
// - `clampToModelMaxOutput` clamps max_tokens/max_completion_tokens/max_output_tokens
// down to the model's catalog `maxOutputTokens` ceiling, when one is set.
// - `maxOutputCap` clamps the same keys down to a fixed endpoint-imposed ceiling
Expand All @@ -23,6 +27,7 @@ type StripRule = {
provider?: string;
match: RegExp | ((model: string) => boolean);
drop?: string[];
dropIfNull?: string[];
clampToModelMaxOutput?: boolean;
maxOutputCap?: number;
};
Expand Down Expand Up @@ -93,6 +98,49 @@ const STRIP_RULES: StripRule[] = [
// to read), hence the fixed cap.
{ provider: "azure-openai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 },
{ provider: "azure-ai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 },
// AI/ML API validates optional fields with a strict schema that rejects a
// literal `null` ("Expected number, received null") instead of reading null as
// "unset". The OpenAI SDKs serialise an unset optional as `null`, so an SDK
// client that never touches temperature still puts `temperature: null` on the
// wire — and OmniRoute relays the caller's body verbatim, so the request 400s.
//
// Field-by-field sweep against POST /v1/chat/completions, 2026-09-03:
// 400 on null, every model: seed, tools, tool_choice, response_format,
// stream, stream_options, parallel_tool_calls,
// max_tokens, max_completion_tokens,
// reasoning_effort
// 400 on null, model-dependent: temperature, top_p (400 on
// claude-sonnet-4.6 and deepseek-chat, 200 on
// gpt-5 — so an integration smoke-tested only
// against gpt-5 looks healthy)
// 200 on null (left alone): stop, presence_penalty, frequency_penalty, n,
// user, logprobs, logit_bias, top_logprobs,
// metadata
//
// Provider-wide, and only for the null value: an explicit `temperature: 0` or
// `stream: false` is forwarded untouched. Dropping is the correct reading —
// every field here means "unset" when the caller sends null.
//
// The 400 body's top-level `message` is generic; `details[].path` / `.reason`
// name the offending field, which is what this list was built from.
{
provider: "aimlapi",
match: /.*/,
dropIfNull: [
"temperature",
"top_p",
"seed",
"tools",
"tool_choice",
"response_format",
"stream",
"stream_options",
"parallel_tool_calls",
"max_tokens",
"max_completion_tokens",
"reasoning_effort",
],
},
];

function matches(rule: StripRule, model: string): boolean {
Expand Down Expand Up @@ -156,6 +204,9 @@ export function stripUnsupportedParams<T>(
for (const key of rule.drop ?? []) {
if (rec[key] !== undefined) delete rec[key];
}
for (const key of rule.dropIfNull ?? []) {
if (rec[key] === null) delete rec[key];
}
applyMaxOutputClamp(rule, provider, model, rec);
}

Expand Down
13 changes: 13 additions & 0 deletions src/app/(dashboard)/dashboard/providers/featuredProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ const KIMI_PROVIDER_IDS: readonly string[] = [
/** Cheaper Inference (api.cheaperinference.com) — apikey category, single id. */
const CHEAPERINFERENCE_PROVIDER_IDS: readonly string[] = ["cheaperinference"];

/**
* aimlapi.com (api.aimlapi.com) — apikey category, single id.
*
* FORK-ONLY. This pin is not part of the upstream provider set and must not be
* carried into an upstream pull request; it is isolated in its own commit so it
* can be dropped wholesale. It is deliberately ranked BELOW the two sponsors the
* operator ranked explicitly on 2026-07-31 — those ranks encode a real
* commitment and are asserted in `featured-providers-rank.test.ts`, so they are
* not ours to reorder.
*/
const AIMLAPI_PROVIDER_IDS: readonly string[] = ["aimlapi"];

/**
* Explicit sponsor ordering for the dashboard provider grids.
*
Expand All @@ -67,6 +79,7 @@ const CHEAPERINFERENCE_PROVIDER_IDS: readonly string[] = ["cheaperinference"];
const FEATURED_PROVIDER_RANKS: ReadonlyMap<string, number> = new Map([
...KIMI_PROVIDER_IDS.map((id) => [id, 1] as const),
...CHEAPERINFERENCE_PROVIDER_IDS.map((id) => [id, 2] as const),
...AIMLAPI_PROVIDER_IDS.map((id) => [id, 3] as const),
]);

/** Brand accent per sponsor family, keyed by any of that family's provider ids. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ import { isDashscopeTextModelId } from "@omniroute/open-sse/services/dashscopeTe
import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts";
import { normalizeOpenAiLikeModelsResponse } from "./normalizers";

/**
* `type` values AI/ML API uses for models routable through /v1/chat/completions.
*
* `openai/chat-completions` is the current vocabulary (353 of 936 catalog rows on
* 2026-09-03); `chat-completion` is the pre-rename spelling, kept so a future
* rename in either direction cannot silently empty the provider's model list
* again. Everything else the catalog publishes — video, image, TTS, STT,
* `openai/responses/submit`, `anthropic/messages`, batches — is a different
* upstream surface and must not reach a chat model picker.
*/
export const AIMLAPI_CHAT_MODEL_TYPES = new Set(["openai/chat-completions", "chat-completion"]);

const QWEN_CLOUD_TEXT_MODEL_IDS = new Set(QWEN_CLOUD_TEXT_MODELS.map((model) => model.id));
const ALIBABA_MODEL_STUDIO_MODEL_IDS = new Set(
ALIBABA_MODEL_STUDIO_MODELS.map((model) => model.id)
Expand Down Expand Up @@ -472,17 +484,32 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
parseResponse: (data) => data.data || [],
},
aimlapi: {
// #5570: AI/ML API's live catalog (400+ models) lives at the public,
// auth-free /models database endpoint (NOT /v1/models). The registry has no
// modelsUrl, so without this entry the route fell back to a stale 6-model
// seed. Response is a bare array of { id, type, info: { name } }.
// #5570: AI/ML API's live catalog lives at the public, auth-free /models
// database endpoint (NOT /v1/models). The registry has no modelsUrl, so
// without this entry the route falls back to the static seed.
//
// The two premises in the original #5570 comment went stale and silently
// zeroed this provider out (936 catalog rows -> 0 discovered models -> the
// 6-entry static seed):
// 1. the response is NOT a bare array — it is the OpenAI-style envelope
// `{ "object": "list", "data": [...] }`, so `Array.isArray(data)` was
// false and every row was discarded before the filter even ran;
// 2. the chat `type` vocabulary is now `openai/chat-completions`; the old
// `chat-completion` spelling matches 0 of 936 rows (verified against the
// live catalog 2026-09-03).
// Both spellings are accepted below so a future vocabulary change degrades to
// "older models missing" rather than "provider has no models", and the bare
// array is still unwrapped for the same reason. Non-chat rows (video, image,
// TTS, batches) are dropped outright — the previous `chat.length ? chat : all`
// fallback would have surfaced 583 non-chat ids in a chat model picker, which
// is what hid defect 2 from the tests.
url: "https://api.aimlapi.com/models",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const all = Array.isArray(data) ? data : [];
const chat = all.filter((m) => m?.type === "chat-completion");
return (chat.length > 0 ? chat : all)
const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
return rows
.filter((m) => AIMLAPI_CHAT_MODEL_TYPES.has(m?.type))
.map((m) => ({ id: m?.id, name: m?.info?.name || m?.id }))
.filter((m) => typeof m.id === "string" && m.id);
},
Expand Down
8 changes: 5 additions & 3 deletions src/shared/constants/providers/apikey/gateways.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
color: "#6366F1",
textIcon: "1M",
website: "https://1min.ai",
authHint: "Create an API key at https://docs.1min.ai/docs/api/create-api-key, then paste it here.",
authHint:
"Create an API key at https://docs.1min.ai/docs/api/create-api-key, then paste it here.",
apiHint:
"1min.ai uses a proprietary chat API (single prompt string + SSE) instead of OpenAI chat/completions. OmniRoute flattens OpenAI messages into a labeled prompt and translates the SSE stream.",
passthroughModels: true,
Expand Down Expand Up @@ -47,7 +48,8 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
website: "https://freebuff.com",
hasFree: true,
serviceKinds: ["llm"],
authHint: "Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).",
authHint:
"Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).",
freeNote: "Free Codebuff / Freebuff AI models.",
apiHint: "Token is authenticated against Codebuff upstream session pool.",
passthroughModels: true,
Expand Down Expand Up @@ -907,7 +909,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
id: "aimlapi",
serviceKinds: ["llm"],
alias: "aiml",
name: "AI/ML API",
name: "aimlapi.com",
icon: "hub",
color: "#6366F1",
textIcon: "AI",
Expand Down
18 changes: 15 additions & 3 deletions tests/snapshots/provider/translate-path.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,28 @@
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/diegosouzapw/OmniRoute",
"X-AIMLAPI-Partner-ID": "part_T2iNtMuQ3JBmEPwyOKCLOxaP",
"X-AIMLAPI-Source": "agent/omniroute",
"X-Title": "OmniRoute"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/diegosouzapw/OmniRoute",
"X-AIMLAPI-Partner-ID": "part_T2iNtMuQ3JBmEPwyOKCLOxaP",
"X-AIMLAPI-Source": "agent/omniroute",
"X-Title": "OmniRoute"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/diegosouzapw/OmniRoute",
"X-AIMLAPI-Partner-ID": "part_T2iNtMuQ3JBmEPwyOKCLOxaP",
"X-AIMLAPI-Source": "agent/omniroute",
"X-Title": "OmniRoute"
}
},
"url": {
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/aimlapi-attribution-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// AI/ML API partner attribution.
//
// A malformed partner id is NOT rejected upstream — the request succeeds and the
// usage is simply recorded as untagged. There is no runtime signal for a typo, so
// the id's shape has to be asserted here or nothing catches it.
import test from "node:test";
import assert from "node:assert/strict";

import { REGISTRY } from "@omniroute/open-sse/config/providers/index.ts";
import { aimlapiProvider } from "@omniroute/open-sse/config/providers/registry/aimlapi/index.ts";
import { generateLegacyProviders } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getDefaultExecutor } from "@omniroute/open-sse/executors/defaultResolver.ts";
import { APIKEY_PROVIDERS } from "@/shared/constants/providers/apikey/index";

/** Gateway contract: /^part_[A-Za-z0-9]{1,64}$/ — no dashes, no underscores. */
const PARTNER_ID_PATTERN = /^part_[A-Za-z0-9]{1,64}$/;
/** `<channel>/<client>`, channel a closed enum, client lowercase alnum + dashes. */
const SOURCE_PATTERN = /^(web|agent|mcp)\/[a-z0-9-]{1,32}$/;

test("aimlapi declares all four attribution headers", () => {
const headers = aimlapiProvider.headers ?? {};
assert.equal(headers["X-AIMLAPI-Partner-ID"], "part_T2iNtMuQ3JBmEPwyOKCLOxaP");
assert.equal(headers["X-AIMLAPI-Source"], "agent/omniroute");
assert.equal(headers["HTTP-Referer"], "https://github.com/diegosouzapw/OmniRoute");
assert.equal(headers["X-Title"], "OmniRoute");
});

test("the partner id matches the gateway's pattern", () => {
// A malformed id is accepted by the API and silently earns nothing, so this
// assertion is the only place a typo surfaces.
const partnerId = aimlapiProvider.headers?.["X-AIMLAPI-Partner-ID"] ?? "";
assert.match(partnerId, PARTNER_ID_PATTERN);
assert.match(aimlapiProvider.headers?.["X-AIMLAPI-Source"] ?? "", SOURCE_PATTERN);
});

test("HTTP-Referer and X-Title identify OmniRoute, not the upstream gateway", () => {
// These are the OpenRouter-convention analytics headers: they name the calling
// application. Pointing them at aimlapi.com would attribute OmniRoute's traffic
// to the provider it is calling.
const headers = aimlapiProvider.headers ?? {};
assert.ok(!headers["HTTP-Referer"].includes("aimlapi.com"));
assert.ok(!headers["X-Title"].toLowerCase().includes("aimlapi"));
});

test("attribution is scoped to aimlapi and cannot ride a request to another provider", () => {
const carriers = Object.entries(REGISTRY)
.filter(([, entry]) =>
Object.keys({ ...(entry.headers ?? {}), ...(entry.extraHeaders ?? {}) }).some((key) =>
key.toLowerCase().startsWith("x-aimlapi-")
)
)
.map(([id]) => id);
assert.deepEqual(carriers, ["aimlapi"]);
});

test("the executor emits the headers without mutating the shared registry constant", () => {
const before = JSON.stringify(aimlapiProvider.headers);
const executor = getDefaultExecutor("aimlapi");

const first = executor.buildHeaders({ apiKey: "test-key" } as never, false);
const second = executor.buildHeaders({ apiKey: "test-key" } as never, false);

assert.equal(first["X-AIMLAPI-Partner-ID"], "part_T2iNtMuQ3JBmEPwyOKCLOxaP");
assert.equal(second["X-AIMLAPI-Partner-ID"], "part_T2iNtMuQ3JBmEPwyOKCLOxaP");
// A fresh object per request — mutating one built header map must not leak into
// the next request or back into the registry entry.
assert.notEqual(first, second);
first["X-AIMLAPI-Partner-ID"] = "part_tampered";
assert.equal(
executor.buildHeaders({ apiKey: "test-key" } as never, false)["X-AIMLAPI-Partner-ID"],
"part_T2iNtMuQ3JBmEPwyOKCLOxaP"
);
assert.equal(JSON.stringify(aimlapiProvider.headers), before);
});

test("the generated legacy provider map carries the headers through to dispatch", () => {
const legacy = generateLegacyProviders().aimlapi;
assert.equal(legacy.headers?.["X-AIMLAPI-Partner-ID"], "part_T2iNtMuQ3JBmEPwyOKCLOxaP");
assert.equal(legacy.headers?.["X-AIMLAPI-Source"], "agent/omniroute");
});

test("the user-facing provider label is exactly aimlapi.com", () => {
// The machine id (`aimlapi`) and alias (`aiml`) are what users' configs and the
// DB reference, so they must not change; only the display string does.
assert.equal(APIKEY_PROVIDERS.aimlapi.name, "aimlapi.com");
assert.equal(APIKEY_PROVIDERS.aimlapi.id, "aimlapi");
assert.equal(APIKEY_PROVIDERS.aimlapi.alias, "aiml");
});
Loading