diff --git a/internal/providers/configs/aimlapi.json b/internal/providers/configs/aimlapi.json new file mode 100644 index 00000000..ae789db1 --- /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_u4BWIyBIiI60n8lwT48mpdkJ", + "X-AIMLAPI-Source": "agent/crush" + } +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index b50a4e8e..3b25b5ab 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 @@ -136,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, @@ -204,6 +209,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, } }