Skip to content

feat: add aimlapi.com as an NLP service - #1

Open
Lookoff-AIMLAPI wants to merge 3 commits into
developfrom
feat/aimlapi-provider
Open

feat: add aimlapi.com as an NLP service#1
Lookoff-AIMLAPI wants to merge 3 commits into
developfrom
feat/aimlapi-provider

Conversation

@Lookoff-AIMLAPI

Copy link
Copy Markdown
Member

Summary

Adds aimlapi.com as a Parlant NLP service: a new adapter at
src/parlant/adapters/nlp/aimlapi_service.py, plus the usual three registry edits
(NLPServiceName, NLP_SERVICE_INITIALIZERS + --aimlapi flag in bin/server.py,
NLPServices.aimlapi in sdk.py), a changelog line and a docs page. Same shape as
the merged Novita adapter (novita_service.py), with the embedder and header handling
taken from openrouter_service.py.

aimlapi.com is an OpenAI-compatible gateway over 353 chat models. It needs no new
dependency
— it reuses the openai client Parlant already ships — and unlike most
aggregators it serves embeddings natively (15 models), so get_embedder() returns a
real remote embedder instead of falling back to the local JinaAIEmbedder.

Why a first-class adapter and not "just use LiteLLM"

That is a fair question here, and the honest answer is a short list:

  • parlant[litellm] pulls in litellm + torch + transformers. This adapter adds
    nothing to the dependency tree.
  • LiteLLMService falls back to local JinaAI embeddings unless
    LITELLM_EMBEDDING_MODEL_NAME is set; here the default embedder is a remote
    openai/text-embedding-3-large, 3072-dim, verified live.
  • Token accounting and the GenerationInfo.model id come straight from the provider
    rather than through a translation layer.

The one thing that could not be copied from an existing adapter

aimlapi.com returns HTTP 400 when an optional field arrives as an explicit JSON
null
— verified today against POST /v1/chat/completions:

rejects null (400) accepts null (200)
temperature, top_p, seed, tools, tool_choice, response_format, stream, stream_options, parallel_tool_calls, max_tokens, max_completion_tokens stop, presence_penalty, frequency_penalty, n, user, logprobs, logit_bias

OpenAI accepts null for all of them. So the natural implementation — forwarding an
unset hint straight through, e.g. temperature=hints.get("temperature") — produces an
adapter that fails on every real call while its unit tests stay green, because the
tests mock the transport.

Every request in this adapter is therefore built through omit_unset_arguments(), which
drops keys whose value is None rather than serialising them. Three tests guard it, and
all three fail if the helper is reduced to dict(arguments) (checked by mutating it):

  • test_that_unset_arguments_are_omitted_rather_than_passed_as_null
  • test_that_a_none_valued_hint_is_never_sent_to_the_api
  • test_that_no_none_valued_argument_is_sent_when_streaming

The 400 body names the offending field in error.details[].path / .reason; the
top-level message is generic ("Validation failed. Please check your request fields…"),
so details is the thing to read when debugging.

Attribution headers

Follows the convention already in openrouter_service.py (HTTP-Referer / X-Title
naming the calling application), plus the two headers aimlapi.com reads:

HTTP-Referer:         https://github.com/emcie-co/parlant
X-Title:              Parlant
X-AIMLAPI-Partner-ID: part_parlant
X-AIMLAPI-Source:     agent/parlant

HTTP-Referer and X-Title are overridable by the host application via
AIMLAPI_HTTP_REFERER / AIMLAPI_SITE_NAME, mirroring OPENROUTER_HTTP_REFERER /
OPENROUTER_SITE_NAME. Properties covered by tests:

  • the header dict is built fresh per client — the module-level constant is a
    MappingProxyType and is never mutated;
  • headers are scoped to api.aimlapi.com: build_attribution_headers() returns
    {} for any other host, so attribution cannot ride a request to another provider or
    to a proxy fronting the same API;
  • the partner id is asserted against ^part_[A-Za-z0-9]{1,64}$. A malformed id is
    ignored by the gateway rather than rejected, so nothing at runtime would ever surface
    a typo — the regex test is the only thing that catches it.

Models

Every id shipped here was checked against GET /v1/models?include=all (936 rows, 785
distinct ids, 353 with type == "openai/chat-completions") as both an id and an
alias, and all four pre-configured chat models publish structured_output and tools
in capabilities:

id context why
openai/gpt-4.1 1,047,576 default; JSON-mode verified live
openai/gpt-4.1-mini 1,000,000 cheaper
anthropic/claude-sonnet-4.5 200,000 long-context reasoning
google/gemini-2.5-flash 1,000,000 fast/cheap

Embedder default openai/text-embedding-3-large (3072 dims, confirmed by a live call,
not by the catalog). No model is removed or renamed by this PR.

Two deliberate omissions, both verified rather than assumed:

  • deepseek/deepseek-chat is not pre-configured even though it is a valid id: it is
    published both as its own model and as an alias of deepseek/deepseek-v4-flash, and a
    request for it is actually served by deepseek-v4-flash. Anything that pins "the model
    that served the request" would see a mismatch.
  • Dotted spellings are used throughout (anthropic/claude-sonnet-4.5). The dashed
    variants of some Anthropic ids publish only streaming in capabilities even though
    the models are identical, so gating on capabilities with a dashed id is misleading.

Verification

Everything below was run on this branch. No AIMLAPI_API_KEY value appears in any
committed file, log or output.

Build / installuv sync --frozen clean; import parlant.bin.server OK;
parlant-server run --help lists --aimlapi.

Testspytest tests/adapters/nlp --continue-on-collection-errors:

baseline (develop, pristine) this branch
passed 37 57
skipped 1 1
collection errors 3 3

The 3 collection errors are pre-existing and identical on a clean tree
(test_azure_service.py, test_litellm_service.py, test_zhipu_service.py — the
azure, litellm and zhipu optional extras are not installed). The 20 new tests are
all in tests/adapters/nlp/test_aimlapi_service.py.

Lintuv run python scripts/lint.py --mypy --ruff: 49 mypy errors in 23 files
before and after, i.e. zero new errors, while the checked file count goes from 289
to 291 (the two new files). All remaining errors are missing stubs for uninstalled
optional extras plus three pre-existing entity_cq.py errors. The two new files are
clean under ruff check and ruff format. Note src/parlant/bin/server.py and most of
adapters/nlp/*.py were already non-conforming to ruff format before this branch;
that was left alone rather than reformatted into the diff.

Live inference through the adapter (not curl, not a mock — via
AIMLAPIService.get_schematic_generator() / .get_embedder() / streaming):

[LIVE] generator id: aimlapi/openai/gpt-4.1 max_tokens: 1047576
[LIVE] schematic content: {'country': 'France', 'capital': 'Paris', 'confidence': 'high'}
[LIVE] schematic info: model=aimlapi/openai/gpt-4.1 duration=1.37s input=34 output=23
[LIVE] embedder id: aimlapi/openai/text-embedding-3-large declared dims: 3072 vectors: 2 actual dims: 3072
[LIVE] streamed: 'streaming works' usage: 13 3

The schematic call above was made with hints={"temperature": None} on purpose: that is
the exact input that 400s if the parameter is forwarded instead of omitted.

Live multi-turn tool loop through the engine — a real p.Server on
NLPServices.aimlapi with a tool attached, two customer turns on one session:

[LIVE-TURN-1] tool calls: ['AA100']
[LIVE-TURN-1] agent: There are 42 seats booked on flight AA100.
[LIVE-TURN-2] tool calls: ['AA100', 'BA200']
[LIVE-TURN-2] agent: There are 42 seats booked on flight BA200.

Turn 2 is the interesting one: a host that clears tools between turns by nulling the
field succeeds on turn 1 and 400s on turn 2. Parlant drives tools through schematic
generation rather than the native tools parameter, so it is not exposed to that
directly — but the loop was exercised end to end rather than assumed.

Commits

  1. feat: add aimlapi.com as an NLP service — the adapter, registry wiring, tests, docs
    and changelog.
  2. chore(aimlapi): fork-only placement — do not send upstream — moves aimlapi.com to
    the front of the hand-ordered provider lists. Drop this commit before offering
    anything upstream.
    It changes no behaviour. Parlant has no
    "recommended"/featured badge concept for providers, so none was invented; list order
    is the only lever.

Not verified

  • Only openai/gpt-4.1 was exercised live; the other three pre-configured ids are
    verified against the catalog (structured_output + tools + context length) but were
    not each driven through a full agent turn.
  • The wider repo suite (tests/core, tests/sdk, tests/api) was not run in full: it
    is an LLM-in-the-loop suite that CI runs against Emcie's own service, and running it
    end to end is neither cheap nor deterministic here. The adapter subset plus the live
    agent run above is what was verified.

aimlapi and others added 3 commits September 3, 2026 06:36
Parlant currently reaches multi-vendor catalogues either through a
vendor-specific adapter or through LiteLLM. LiteLLM covers the routing but
brings a heavy optional dependency, forces the local JinaAI fallback for
embeddings on most setups, and hides per-model usage behind its own
translation layer.

aimlapi.com is an OpenAI-compatible gateway that also serves embeddings
natively, so a first-class adapter gives Parlant a single key for 350+ chat
models *and* a real remote embedder — with correct token accounting and no
new dependency, since it reuses the `openai` client Parlant already ships.

The one thing this adapter cannot copy from the other OpenAI-compatible
services is how unset parameters are sent. aimlapi.com answers 400 when
`temperature`, `top_p`, `seed`, `tools`, `tool_choice`, `response_format`,
`stream`, `stream_options`, `parallel_tool_calls`, `max_tokens` or
`max_completion_tokens` arrive as an explicit JSON null, while OpenAI accepts
null for all of them. Forwarding a `None` hint — the obvious implementation —
therefore fails on every real call while mocked tests stay green, so every
request is built by omitting unset keys and three tests guard that invariant.

Attribution headers follow the existing OpenRouter convention (HTTP-Referer /
X-Title naming the calling application, here Parlant) plus the two headers the
provider reads for partner attribution. They are built fresh per client and
scoped to the api.aimlapi.com host, so they cannot ride along to another
provider or to a proxy fronting the same API.

Signed-off-by: aimlapi <opensource@aimlapi.com>
Moves aimlapi.com to the front of the three hand-ordered provider lists:
the `NLPServiceName` literal, the `NLP_SERVICE_INITIALIZERS` map and the
`parlant-server run` provider flags, plus the `NLPServices` factories in
sdk.py.

This is placement, not function — nothing here changes behaviour, and it
is deliberately isolated in its own commit so it can be dropped before the
change is offered upstream. Parlant has no "recommended"/featured badge
mechanism for providers, so none was invented; the only lever available is
list order.

Signed-off-by: aimlapi <opensource@aimlapi.com>
The placeholder part_parlant was a readable stand-in chosen before the
partner was registered. Registration mints the id server-side, so the
real value is part_UOT3mCwOdpOUQKX2gIvYrCmv. 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