From c4f204d36f70378accf2ad0d96b83fbfcbb442f2 Mon Sep 17 00:00:00 2001 From: Lookoff-AIMLAPI Date: Thu, 3 Sep 2026 04:10:43 +0500 Subject: [PATCH 1/3] feat(aimlapi): add aimlapi.com provider Crush users who buy their inference through aimlapi.com currently have to hand-write a custom provider block; catwalk is where that belongs so the model list, prices and context windows stay maintained in one place. The endpoint is OpenAI-compatible, so `openai-compat` covers it. Nine chat models are curated by hand rather than generated: the catalog carries 353 chat entries, which is not a useful model picker, and importing it wholesale would churn on every upstream catalog change. Attribution rides in `default_headers`, the seam the repo already uses for this (aihubmix's APP-Code, openrouter's HTTP-Referer/X-Title). HTTP-Referer and X-Title name Crush, matching openrouter.json and vercel.json, because they identify the calling application, not the gateway. The partner id is covered by a regex test: the gateway ignores a malformed value instead of rejecting the request, so a typo would never show up at runtime. The same test asserts no other provider carries an X-AIMLAPI-* header, so attribution cannot ride along to somebody else's API. --- internal/providers/configs/aimlapi.json | 179 ++++++++++++++++++++++++ internal/providers/providers.go | 8 ++ internal/providers/providers_test.go | 34 +++++ pkg/catwalk/provider.go | 2 + 4 files changed, 223 insertions(+) create mode 100644 internal/providers/configs/aimlapi.json diff --git a/internal/providers/configs/aimlapi.json b/internal/providers/configs/aimlapi.json new file mode 100644 index 00000000..2189c1f6 --- /dev/null +++ b/internal/providers/configs/aimlapi.json @@ -0,0 +1,179 @@ +{ + "name": "aimlapi.com", + "id": "aimlapi", + "api_key": "$AIMLAPI_API_KEY", + "api_endpoint": "https://api.aimlapi.com/v1", + "type": "openai-compat", + "default_large_model_id": "openai/gpt-5.6-terra", + "default_small_model_id": "openai/gpt-5.6-luna", + "models": [ + { + "id": "openai/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "cost_per_1m_in": 2.6, + "cost_per_1m_out": 15.6, + "cost_per_1m_in_cached": 3.25, + "cost_per_1m_out_cached": 0.26, + "context_window": 1050000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "openai/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "cost_per_1m_in": 0.26, + "cost_per_1m_out": 1.56, + "cost_per_1m_in_cached": 0.325, + "cost_per_1m_out_cached": 0.026, + "context_window": 1050000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "openai/gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "cost_per_1m_in": 4.8139, + "cost_per_1m_out": 38.5112, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0.48139, + "context_window": 400000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "cost_per_1m_in": 6.5, + "cost_per_1m_out": 32.5, + "cost_per_1m_in_cached": 8.125, + "cost_per_1m_out_cached": 0.65, + "context_window": 1000000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "anthropic/claude-sonnet-5", + "name": "Claude Sonnet 5", + "cost_per_1m_in": 2.6, + "cost_per_1m_out": 13, + "cost_per_1m_in_cached": 3.25, + "cost_per_1m_out_cached": 0.26, + "context_window": 1000000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "anthropic/claude-haiku-4.5", + "name": "Claude 4.5 Haiku", + "cost_per_1m_in": 1.3754, + "cost_per_1m_out": 6.877, + "cost_per_1m_in_cached": 1.71925, + "cost_per_1m_out_cached": 0.13754, + "context_window": 200000, + "default_max_tokens": 64000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "google/gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "cost_per_1m_in": 0.975, + "cost_per_1m_out": 4.875, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0.0975, + "context_window": 1048576, + "default_max_tokens": 65536, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "cost_per_1m_in": 0.5655, + "cost_per_1m_out": 1.131, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0.0047125, + "context_window": 1000000, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": false + }, + { + "id": "z-ai/glm-5.3-flash", + "name": "GLM 5.3 Flash", + "cost_per_1m_in": 0.0975, + "cost_per_1m_out": 0.325, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0.0195, + "context_window": 1048576, + "default_max_tokens": 128000, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + } + ], + "default_headers": { + "HTTP-Referer": "https://charm.land", + "X-Title": "Crush", + "X-AIMLAPI-Partner-ID": "part_crush", + "X-AIMLAPI-Source": "agent/crush" + } +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index b50a4e8e..3b956e9d 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -12,6 +12,9 @@ import ( //go:embed configs/aihubmix.json var aiHubMixConfig []byte +//go:embed configs/aimlapi.json +var aimlAPIConfig []byte + //go:embed configs/alibaba-singapore.json var alibabaSingaporeConfig []byte @@ -150,6 +153,7 @@ var providerRegistry = []ProviderFunc{ // The remaining will be in alphabetical order. aiHubMixProvider, + aimlAPIProvider, alibabaSingaporeProvider, alibabaUnitedStatesProvider, atlasCloudProvider, @@ -204,6 +208,10 @@ func aiHubMixProvider() catwalk.Provider { return loadProviderFromConfig(aiHubMixConfig) } +func aimlAPIProvider() catwalk.Provider { + return loadProviderFromConfig(aimlAPIConfig) +} + func alibabaSingaporeProvider() catwalk.Provider { return loadProviderFromConfig(alibabaSingaporeConfig) } diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go index ffaa4774..ed5d25f5 100644 --- a/internal/providers/providers_test.go +++ b/internal/providers/providers_test.go @@ -1,8 +1,12 @@ package providers import ( + "regexp" "slices" + "strings" "testing" + + "charm.land/catwalk/pkg/catwalk" ) func TestValidDefaultModels(t *testing.T) { @@ -21,3 +25,33 @@ func TestValidDefaultModels(t *testing.T) { }) } } + +// aimlapiPartnerIDPattern is the shape the aimlapi.com gateway accepts for +// X-AIMLAPI-Partner-ID. A value that does not match is silently ignored +// server-side, so a typo here would never surface at runtime. +var aimlapiPartnerIDPattern = regexp.MustCompile(`^part_[A-Za-z0-9]{1,64}$`) + +func TestAIMLAPIDefaultHeaders(t *testing.T) { + var found bool + for _, p := range GetAll() { + if p.ID != catwalk.InferenceProviderAIMLAPI { + // Attribution headers must never ride along to another provider. + for k := range p.DefaultHeaders { + if strings.HasPrefix(strings.ToLower(k), "x-aimlapi-") { + t.Errorf("provider %q must not send %q", p.ID, k) + } + } + continue + } + found = true + if got := p.DefaultHeaders["X-AIMLAPI-Partner-ID"]; !aimlapiPartnerIDPattern.MatchString(got) { + t.Errorf("X-AIMLAPI-Partner-ID %q does not match %s", got, aimlapiPartnerIDPattern) + } + if got, want := p.DefaultHeaders["X-AIMLAPI-Source"], "agent/crush"; got != want { + t.Errorf("X-AIMLAPI-Source = %q, want %q", got, want) + } + } + if !found { + t.Fatalf("provider %q not registered", catwalk.InferenceProviderAIMLAPI) + } +} diff --git a/pkg/catwalk/provider.go b/pkg/catwalk/provider.go index 7750698d..4183662f 100644 --- a/pkg/catwalk/provider.go +++ b/pkg/catwalk/provider.go @@ -60,6 +60,7 @@ const ( InferenceProviderBaseten InferenceProvider = "baseten" InferenceProviderMoonshot InferenceProvider = "moonshot" InferenceProviderAtlasCloud InferenceProvider = "atlascloud" + InferenceProviderAIMLAPI InferenceProvider = "aimlapi" ) // Provider represents an AI provider configuration. @@ -140,6 +141,7 @@ func KnownProviders() []InferenceProvider { InferenceProviderBaseten, InferenceProviderMoonshot, InferenceProviderAtlasCloud, + InferenceProviderAIMLAPI, } } From 0ed29c1449ba3cfc6a98709dc9c9f08d80a98a28 Mon Sep 17 00:00:00 2001 From: Lookoff-AIMLAPI Date: Thu, 3 Sep 2026 04:11:49 +0500 Subject: [PATCH 2/3] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves aimlapi.com to the head of providerRegistry so it lands first in /v2/providers and therefore first in Crush's provider picker. This is a partnership placement decision, not a correctness change, and it contradicts the comment the registry already carries ("Let's keep the main providers at the top", then alphabetical). It is kept as its own commit so it can be dropped before anything is offered upstream, where the entry belongs in its alphabetical slot after aihubmix. catwalk has no "recommended" or "featured" flag on a provider, so there is nothing to badge; ordering is the only prominence mechanism that exists. --- internal/providers/providers.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 3b956e9d..3b25b5ab 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -139,6 +139,8 @@ var zhipuCodingConfig []byte type ProviderFunc func() catwalk.Provider var providerRegistry = []ProviderFunc{ + aimlAPIProvider, + // Let's keep the main providers at the top. anthropicProvider, openAIProvider, @@ -153,7 +155,6 @@ var providerRegistry = []ProviderFunc{ // The remaining will be in alphabetical order. aiHubMixProvider, - aimlAPIProvider, alibabaSingaporeProvider, alibabaUnitedStatesProvider, atlasCloudProvider, From b06672620fb66b784631b2ff46be562735e7ab48 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:09:45 +0500 Subject: [PATCH 3/3] fix(aimlapi): use the registered partner id The placeholder part_crush was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_u4BWIyBIiI60n8lwT48mpdkJ. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- internal/providers/configs/aimlapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/providers/configs/aimlapi.json b/internal/providers/configs/aimlapi.json index 2189c1f6..ae789db1 100644 --- a/internal/providers/configs/aimlapi.json +++ b/internal/providers/configs/aimlapi.json @@ -173,7 +173,7 @@ "default_headers": { "HTTP-Referer": "https://charm.land", "X-Title": "Crush", - "X-AIMLAPI-Partner-ID": "part_crush", + "X-AIMLAPI-Partner-ID": "part_u4BWIyBIiI60n8lwT48mpdkJ", "X-AIMLAPI-Source": "agent/crush" } }