From 06f7d889015b967a5a1f954c0fc24093ce03cc07 Mon Sep 17 00:00:00 2001 From: Stan Date: Wed, 23 Sep 2026 14:14:48 +0500 Subject: [PATCH 1/4] feat: add AI/ML API as a provider with ten routes AI/ML API is an OpenAI-compatible gateway reached with one key, so it joins the registry as data: config/aimlapi/ ships the provider descriptor and ten model fragments, and the credential, discovery, catalog, tray and installer paths pick it up unchanged. Every capability claim in those fragments is measured against the gateway on 2026-09-23, not copied from a sibling reseller: - reasoning levels, from a 10x6 sweep with a 16k output ceiling so a model's own thinking budget never masqueraded as a rejected effort. gpt-6-sol/luna and deepseek-v4.1-flash take low/medium/high; gemini-3.8-flash rejects xhigh but accepts max; kimi-k3 takes low/high/max; glm-5.2 and grok-4.7 take the full ladder including minimal; qwen3.7-max takes low/medium/high, though its medium needs an output ceiling above 32k. - inputModalities, by sending an image to each: glm-5.2, grok-4.7 and qwen3.7-max are text-only on this route and are declared so. - tool calls, on all ten. Attribution (src/aimlapi-attribution.mjs) is gated on the destination host rather than the provider id, which is the only rule that stays true in both directions: AIMLAPI_API_BASE_URL can move our provider off the gateway, and any other provider's override can move it on. Exact hostname match, so api.aimlapi.com.example.net never receives the headers. The partner id is empty until AI/ML API mints one -- an unknown id is accepted and dropped upstream, so a placeholder would be indistinguishable from a working one. provider-usage reads GET /v1/key, which reports the calling key's scopes, an optional spend limit and month-to-date usage. That is a spend report, not a wallet, so a numeric limit becomes a real quota card and the money otherwise goes in the message rather than into a `balance` metric claiming to be what is left. Routes are priority 1-10 so the gateway leads the picker in this fork. Verified live: a real forwarder process routes aimlapi-gpt-6-luna and aimlapi-claude-sonnet-5 to the gateway and both answer. The full suite (351 files) passes; the new test file also proves the seam, by flipping the host gate to the loopback the test owns and watching the headers arrive. --- .github/workflows/model-discovery.yml | 1 + README.md | 10 + .../Resources/PROVIDER-ICON-SOURCES.md | 1 + .../Sources/IslandOverlay.swift | 4 +- .../Sources/ModelRouterTrayApp.swift | 1 + .../Resources/ProviderIcons/aimlapi.svg | 12 + config/aimlapi/aimlapi.json | 24 ++ config/aimlapi/claude-opus-5.5.json | 45 ++++ config/aimlapi/claude-sonnet-5.json | 45 ++++ config/aimlapi/deepseek-v4.1-flash.json | 37 ++++ config/aimlapi/gemini-3.8-flash.json | 41 ++++ config/aimlapi/glm-5.2.json | 48 ++++ config/aimlapi/gpt-6-luna.json | 37 ++++ config/aimlapi/gpt-6-sol.json | 37 ++++ config/aimlapi/grok-4.7.json | 48 ++++ config/aimlapi/kimi-k3.json | 37 ++++ config/aimlapi/qwen3.7-max.json | 36 +++ .../src/content/docs/providers/overview.md | 10 + src/aimlapi-attribution.mjs | 68 ++++++ src/api-forwarder.mjs | 4 + src/model-discovery.mjs | 2 + src/provider-account-usage.mjs | 64 ++++++ test/aimlapi-attribution.test.mjs | 205 ++++++++++++++++++ test/provider-catalogs.test.mjs | 4 +- test/registry.test.mjs | 10 + 25 files changed, 828 insertions(+), 3 deletions(-) create mode 100644 apps/macos/ModelRouterTray/Sources/Resources/ProviderIcons/aimlapi.svg create mode 100644 config/aimlapi/aimlapi.json create mode 100644 config/aimlapi/claude-opus-5.5.json create mode 100644 config/aimlapi/claude-sonnet-5.json create mode 100644 config/aimlapi/deepseek-v4.1-flash.json create mode 100644 config/aimlapi/gemini-3.8-flash.json create mode 100644 config/aimlapi/glm-5.2.json create mode 100644 config/aimlapi/gpt-6-luna.json create mode 100644 config/aimlapi/gpt-6-sol.json create mode 100644 config/aimlapi/grok-4.7.json create mode 100644 config/aimlapi/kimi-k3.json create mode 100644 config/aimlapi/qwen3.7-max.json create mode 100644 src/aimlapi-attribution.mjs create mode 100644 test/aimlapi-attribution.test.mjs diff --git a/.github/workflows/model-discovery.yml b/.github/workflows/model-discovery.yml index 48e0a101..95f412a8 100644 --- a/.github/workflows/model-discovery.yml +++ b/.github/workflows/model-discovery.yml @@ -53,6 +53,7 @@ jobs: # Only secrets present in repository settings become configured # provider credentials. Scope them to this step so checkout, setup, # and npm lifecycle scripts never receive provider keys. + AIMLAPI_API_KEY: ${{ secrets.AIMLAPI_API_KEY }} AINETCAFE_API_KEY: ${{ secrets.AINETCAFE_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} diff --git a/README.md b/README.md index a4e412e4..4b8a5f27 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,16 @@ Linux installations support the Codex CLI. | Picker label | Model ID | Authentication | | --- | --- | --- | +| GPT-6 Sol (AI/ML API) | `aimlapi/gpt-6-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | +| GPT-6 Luna (AI/ML API) | `aimlapi/gpt-6-luna` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Opus 5.5 (AI/ML API) | `aimlapi/claude-opus-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Sonnet 5 (AI/ML API) | `aimlapi/claude-sonnet-5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Gemini 3.8 Flash (AI/ML API) | `aimlapi/gemini-3.8-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | +| DeepSeek V4.1 Flash (AI/ML API) | `aimlapi/deepseek-v4.1-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | +| GLM 5.2 (AI/ML API) | `aimlapi/glm-5.2` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Kimi K3 (AI/ML API) | `aimlapi/kimi-k3` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Grok 4.7 (AI/ML API) | `aimlapi/grok-4.7` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Qwen3.7 Max (AI/ML API) | `aimlapi/qwen3.7-max` | AI/ML API key (`AIMLAPI_API_KEY`) | | K2.7 Coding Highspeed (OAuth) | `kimi-oauth/kimi-for-coding-highspeed` | Existing Kimi Code CLI OAuth session | | K2.7 Coding (OAuth) | `kimi-oauth/kimi-for-coding` | Existing Kimi Code CLI OAuth session | | Kimi K3 (OAuth) | `kimi-oauth/k3` | Existing Kimi Code CLI OAuth session | diff --git a/apps/macos/ModelRouterTray/Resources/PROVIDER-ICON-SOURCES.md b/apps/macos/ModelRouterTray/Resources/PROVIDER-ICON-SOURCES.md index add58a16..09980351 100644 --- a/apps/macos/ModelRouterTray/Resources/PROVIDER-ICON-SOURCES.md +++ b/apps/macos/ModelRouterTray/Resources/PROVIDER-ICON-SOURCES.md @@ -29,6 +29,7 @@ blue accent and DeepSeek's original blue mark are preserved. | NanoGPT | https://nano-gpt.com/ | https://nano-gpt.com/favicon.ico (same official diamond mark as https://nano-gpt.com/logo.png) | | Google Cloud Vertex AI | https://cloud.google.com/vertex-ai/ | https://www.google.com/s2/favicons?domain=cloud.google.com&sz=128 (Google mark, bundled as `google.svg`) | | StepFun | https://www.stepfun.com/ | https://www.stepfun.com/step_favicon.svg | +| AI/ML API | https://aimlapi.com/ | Official hexagon mark from the AI/ML API app (`sidebar-logo.svg`) | The Z.AI, Qwen, Ollama, Cline, MiniMax, and Meta AI marks were fetched on 2026-08-15. The Venice, Nous Research, and OpenRouter marks were fetched on diff --git a/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift b/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift index df12a12e..9f75ba0e 100644 --- a/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift +++ b/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift @@ -1085,6 +1085,7 @@ struct ProviderIcon: View { if providerID == "venice" { return "venice" } if providerID == "nousresearch" { return "nousresearch" } if providerID == "openrouter" { return "openrouter" } + if providerID == "aimlapi" { return "aimlapi" } if providerID == "nano-gpt" { return "nano-gpt" } // opencode-free plus the opencode-go API/Messages/Responses routes. if providerID.hasPrefix("opencode") { return "opencode-free" } @@ -1104,7 +1105,7 @@ struct ProviderIcon: View { private var assetExtension: String { // Keyed off the asset, not the provider id, so every route sharing a mark // (opencode-go and friends) resolves the same file type. - ["github-copilot", "chutes", "google", "opencode-free", "kilo-free", "nano-gpt", "stepfun"] + ["github-copilot", "chutes", "google", "opencode-free", "kilo-free", "nano-gpt", "stepfun", "aimlapi"] .contains(assetName ?? "") ? "svg" : "png" } @@ -1125,6 +1126,7 @@ struct ProviderIcon: View { if providerID == "venice" { return "Venice" } if providerID == "nousresearch" { return "Nous Research" } if providerID == "openrouter" { return "OpenRouter" } + if providerID == "aimlapi" { return "AI/ML API" } if providerID == "nano-gpt" { return "NanoGPT" } if providerID == "opencode-free" { return "OpenCode Free" } if providerID == "kilo-free" { return "Kilo Free" } diff --git a/apps/macos/ModelRouterTray/Sources/ModelRouterTrayApp.swift b/apps/macos/ModelRouterTray/Sources/ModelRouterTrayApp.swift index feb9636a..2f71dbfe 100644 --- a/apps/macos/ModelRouterTray/Sources/ModelRouterTrayApp.swift +++ b/apps/macos/ModelRouterTray/Sources/ModelRouterTrayApp.swift @@ -1624,6 +1624,7 @@ final class RouterStore: ObservableObject { "orca": "OrcaRouter", "venice": "Venice", "nousresearch": "Nous", + "aimlapi": "AI/ML API", "openrouter": "OpenRouter", ] diff --git a/apps/macos/ModelRouterTray/Sources/Resources/ProviderIcons/aimlapi.svg b/apps/macos/ModelRouterTray/Sources/Resources/ProviderIcons/aimlapi.svg new file mode 100644 index 00000000..39b10a33 --- /dev/null +++ b/apps/macos/ModelRouterTray/Sources/Resources/ProviderIcons/aimlapi.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/config/aimlapi/aimlapi.json b/config/aimlapi/aimlapi.json new file mode 100644 index 00000000..b6b565f7 --- /dev/null +++ b/config/aimlapi/aimlapi.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "providers": [ + { + "id": "aimlapi", + "displayName": "AI/ML API", + "kind": "openai-compatible", + "ownedBy": "aimlapi", + "baseUrl": "https://api.aimlapi.com/v1", + "baseUrlEnv": "AIMLAPI_API_BASE_URL", + "credential": { + "environment": [ + "AIMLAPI_API_KEY" + ], + "file": "aimlapi-api-key.secret", + "legacyFiles": [], + "keychainServices": [ + "codex-router-aimlapi" + ], + "prompt": "AI/ML API key" + } + } + ] +} diff --git a/config/aimlapi/claude-opus-5.5.json b/config/aimlapi/claude-opus-5.5.json new file mode 100644 index 00000000..9c015203 --- /dev/null +++ b/config/aimlapi/claude-opus-5.5.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/claude-opus-5.5", + "gatewayModel": "aimlapi-claude-opus-5-5", + "upstreamModel": "anthropic/claude-opus-5.5", + "provider": "aimlapi", + "listed": true, + "displayName": "Claude Opus 5.5 (AI/ML API)", + "description": "Anthropic Claude Opus 5.5 through AI/ML API's OpenAI-compatible surface; the full effort ladder up to max.", + "priority": 3, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "xhigh", + "description": "Extra deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1000000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-claude-opus-5-5-v1" + } + ] +} diff --git a/config/aimlapi/claude-sonnet-5.json b/config/aimlapi/claude-sonnet-5.json new file mode 100644 index 00000000..be874367 --- /dev/null +++ b/config/aimlapi/claude-sonnet-5.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/claude-sonnet-5", + "gatewayModel": "aimlapi-claude-sonnet-5", + "upstreamModel": "anthropic/claude-sonnet-5", + "provider": "aimlapi", + "listed": true, + "displayName": "Claude Sonnet 5 (AI/ML API)", + "description": "Anthropic Claude Sonnet 5 through AI/ML API; balanced coding model with the full effort ladder.", + "priority": 4, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "xhigh", + "description": "Extra deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1000000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-claude-sonnet-5-v1" + } + ] +} diff --git a/config/aimlapi/deepseek-v4.1-flash.json b/config/aimlapi/deepseek-v4.1-flash.json new file mode 100644 index 00000000..62a87fd3 --- /dev/null +++ b/config/aimlapi/deepseek-v4.1-flash.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/deepseek-v4.1-flash", + "gatewayModel": "aimlapi-deepseek-v4-1-flash", + "upstreamModel": "deepseek/deepseek-v4.1-flash", + "provider": "aimlapi", + "listed": true, + "displayName": "DeepSeek V4.1 Flash (AI/ML API)", + "description": "DeepSeek V4.1 Flash through AI/ML API; fast reasoning with tool calls and image understanding.", + "priority": 6, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + } + ], + "contextWindow": 1048576, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-deepseek-v4-1-flash-v1" + } + ] +} diff --git a/config/aimlapi/gemini-3.8-flash.json b/config/aimlapi/gemini-3.8-flash.json new file mode 100644 index 00000000..96299809 --- /dev/null +++ b/config/aimlapi/gemini-3.8-flash.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/gemini-3.8-flash", + "gatewayModel": "aimlapi-gemini-3-8-flash", + "upstreamModel": "google/gemini-3.8-flash", + "provider": "aimlapi", + "listed": true, + "displayName": "Gemini 3.8 Flash (AI/ML API)", + "description": "Google Gemini 3.8 Flash through AI/ML API; 1M context, text and image input. Rejects xhigh, accepts max.", + "priority": 5, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1048576, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-gemini-3-8-flash-v1" + } + ] +} diff --git a/config/aimlapi/glm-5.2.json b/config/aimlapi/glm-5.2.json new file mode 100644 index 00000000..600e6498 --- /dev/null +++ b/config/aimlapi/glm-5.2.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/glm-5.2", + "gatewayModel": "aimlapi-glm-5-2", + "upstreamModel": "zhipu/glm-5.2", + "provider": "aimlapi", + "listed": true, + "displayName": "GLM 5.2 (AI/ML API)", + "description": "Zhipu GLM 5.2 through AI/ML API; text only on this route, and the only model here that accepts every effort level.", + "priority": 7, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "minimal", + "description": "Minimal reasoning" + }, + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "xhigh", + "description": "Extra deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1000000, + "autoCompact": 900000, + "inputModalities": [ + "text" + ], + "compHash": "aimlapi-glm-5-2-v1" + } + ] +} diff --git a/config/aimlapi/gpt-6-luna.json b/config/aimlapi/gpt-6-luna.json new file mode 100644 index 00000000..b1c1f97e --- /dev/null +++ b/config/aimlapi/gpt-6-luna.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/gpt-6-luna", + "gatewayModel": "aimlapi-gpt-6-luna", + "upstreamModel": "openai/gpt-6-luna", + "provider": "aimlapi", + "listed": true, + "displayName": "GPT-6 Luna (AI/ML API)", + "description": "OpenAI GPT-6 Luna through AI/ML API; the fast, cost-efficient GPT-6 for high-volume work, 1.05M context.", + "priority": 2, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + } + ], + "contextWindow": 1050000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-gpt-6-luna-v1" + } + ] +} diff --git a/config/aimlapi/gpt-6-sol.json b/config/aimlapi/gpt-6-sol.json new file mode 100644 index 00000000..89c24e61 --- /dev/null +++ b/config/aimlapi/gpt-6-sol.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/gpt-6-sol", + "gatewayModel": "aimlapi-gpt-6-sol", + "upstreamModel": "openai/gpt-6-sol", + "provider": "aimlapi", + "listed": true, + "displayName": "GPT-6 Sol (AI/ML API)", + "description": "OpenAI GPT-6 Sol through AI/ML API; built for complex coding and agentic work, 1.05M context, text and image input.", + "priority": 1, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + } + ], + "contextWindow": 1050000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-gpt-6-sol-v1" + } + ] +} diff --git a/config/aimlapi/grok-4.7.json b/config/aimlapi/grok-4.7.json new file mode 100644 index 00000000..f735886b --- /dev/null +++ b/config/aimlapi/grok-4.7.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/grok-4.7", + "gatewayModel": "aimlapi-grok-4-7", + "upstreamModel": "x-ai/grok-4-7", + "provider": "aimlapi", + "listed": true, + "displayName": "Grok 4.7 (AI/ML API)", + "description": "xAI Grok 4.7 through AI/ML API; text only on this route, full effort ladder.", + "priority": 9, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "minimal", + "description": "Minimal reasoning" + }, + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "xhigh", + "description": "Extra deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 500000, + "autoCompact": 440000, + "inputModalities": [ + "text" + ], + "compHash": "aimlapi-grok-4-7-v1" + } + ] +} diff --git a/config/aimlapi/kimi-k3.json b/config/aimlapi/kimi-k3.json new file mode 100644 index 00000000..a8bb4b69 --- /dev/null +++ b/config/aimlapi/kimi-k3.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/kimi-k3", + "gatewayModel": "aimlapi-kimi-k3", + "upstreamModel": "moonshot/kimi-k3", + "provider": "aimlapi", + "listed": true, + "displayName": "Kimi K3 (AI/ML API)", + "description": "Moonshot Kimi K3 through AI/ML API; 1M context with image input. Accepts low, high and max only.", + "priority": 8, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1048576, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-kimi-k3-v1" + } + ] +} diff --git a/config/aimlapi/qwen3.7-max.json b/config/aimlapi/qwen3.7-max.json new file mode 100644 index 00000000..24559486 --- /dev/null +++ b/config/aimlapi/qwen3.7-max.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/qwen3.7-max", + "gatewayModel": "aimlapi-qwen3-7-max", + "upstreamModel": "alibaba/qwen3.7-max", + "provider": "aimlapi", + "listed": true, + "displayName": "Qwen3.7 Max (AI/ML API)", + "description": "Alibaba Qwen3.7 Max through AI/ML API; text only. Its thinking budget is large, so medium effort needs an output ceiling above 32k tokens.", + "priority": 10, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + } + ], + "contextWindow": 1000000, + "autoCompact": 900000, + "inputModalities": [ + "text" + ], + "compHash": "aimlapi-qwen3-7-max-v1" + } + ] +} diff --git a/docs-site/src/content/docs/providers/overview.md b/docs-site/src/content/docs/providers/overview.md index 5aac961c..1d637f58 100644 --- a/docs-site/src/content/docs/providers/overview.md +++ b/docs-site/src/content/docs/providers/overview.md @@ -4,6 +4,16 @@ description: "Connect provider access without putting secrets in shell history." --- | Picker label | Model ID | Authentication | | --- | --- | --- | +| GPT-6 Sol (AI/ML API) | `aimlapi/gpt-6-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | +| GPT-6 Luna (AI/ML API) | `aimlapi/gpt-6-luna` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Opus 5.5 (AI/ML API) | `aimlapi/claude-opus-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Sonnet 5 (AI/ML API) | `aimlapi/claude-sonnet-5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Gemini 3.8 Flash (AI/ML API) | `aimlapi/gemini-3.8-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | +| DeepSeek V4.1 Flash (AI/ML API) | `aimlapi/deepseek-v4.1-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | +| GLM 5.2 (AI/ML API) | `aimlapi/glm-5.2` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Kimi K3 (AI/ML API) | `aimlapi/kimi-k3` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Grok 4.7 (AI/ML API) | `aimlapi/grok-4.7` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Qwen3.7 Max (AI/ML API) | `aimlapi/qwen3.7-max` | AI/ML API key (`AIMLAPI_API_KEY`) | | K2.7 Coding Highspeed (OAuth) | `kimi-oauth/kimi-for-coding-highspeed` | Existing Kimi Code CLI OAuth session | | K2.7 Coding (OAuth) | `kimi-oauth/kimi-for-coding` | Existing Kimi Code CLI OAuth session | | Kimi K3 (OAuth) | `kimi-oauth/k3` | Existing Kimi Code CLI OAuth session | diff --git a/src/aimlapi-attribution.mjs b/src/aimlapi-attribution.mjs new file mode 100644 index 00000000..74049887 --- /dev/null +++ b/src/aimlapi-attribution.mjs @@ -0,0 +1,68 @@ +// AI/ML API counts a request toward an integration only when it carries these +// headers, so every request the router sends to the gateway carries them -- +// and nothing else does. The gate is the *destination host*, not the provider +// id: `AIMLAPI_API_BASE_URL` can move the aimlapi provider somewhere else, and +// any other provider's baseUrl override can move it here. Attribution follows +// the address, which is the only thing that is true either way. +// +// Matching is exact on the hostname. A suffix test would hand the partner id +// to `api.aimlapi.com.example.net`, which is precisely the kind of host an +// override exists to be careful about. + +import { resolveProviderBaseUrl } from "./model-registry.mjs"; + +export const AIMLAPI_HOST = "api.aimlapi.com"; +export const AIMLAPI_SOURCE_HEADER = "x-aimlapi-source"; +export const AIMLAPI_PARTNER_HEADER = "x-aimlapi-partner-id"; + +export const AIMLAPI_SOURCE = "agent/codex-router"; +// Minted by AI/ML API, not by us. Empty means "not registered yet": an unknown +// partner id is accepted and silently dropped upstream, so sending a +// placeholder would look identical to working and count nothing. +export const AIMLAPI_PARTNER_ID = ""; + +const REFERER = "https://github.com/duolahypercho/codex-router"; +const TITLE = "Codex Router"; + +export function isAimlapiBaseUrl(baseUrl) { + if (typeof baseUrl !== "string" || !baseUrl.trim()) return false; + try { + return new URL(baseUrl).hostname.toLowerCase() === AIMLAPI_HOST; + } catch { + return false; + } +} + +export function aimlapiAttributionHeaders() { + const headers = { + "HTTP-Referer": REFERER, + "X-Title": TITLE, + "X-AIMLAPI-Source": AIMLAPI_SOURCE, + }; + if (AIMLAPI_PARTNER_ID) headers["X-AIMLAPI-Partner-ID"] = AIMLAPI_PARTNER_ID; + return headers; +} + +// Where a request to this endpoint will actually land, honouring the same +// baseUrl override the forwarder honours. A vertex endpoint builds its URL +// elsewhere and never reaches the gateway, so it resolves to nothing. +export function endpointDestination(endpoint, env = process.env) { + if (!endpoint || typeof endpoint !== "object") return ""; + if (endpoint.protocol === "vertex") return ""; + try { + return resolveProviderBaseUrl(endpoint, env).baseUrl || ""; + } catch { + return ""; + } +} + +// Returns whether anything was attached, so a caller (and a test) can tell +// "not our host" from "our host, nothing to send". Pass `endpoint` to have the +// destination resolved (including its env override), or `baseUrl` when the +// caller has already resolved it. +export function applyAimlapiAttributionHeaders(target, { endpoint, baseUrl, env } = {}) { + const destination = baseUrl !== undefined ? baseUrl : endpointDestination(endpoint, env); + if (!isAimlapiBaseUrl(destination)) return false; + Object.assign(target, aimlapiAttributionHeaders()); + return true; +} diff --git a/src/api-forwarder.mjs b/src/api-forwarder.mjs index 857550ba..310f1ffa 100644 --- a/src/api-forwarder.mjs +++ b/src/api-forwarder.mjs @@ -74,6 +74,7 @@ import { normalizeOpenAIRequest, } from "./openai-adapters.mjs"; import { threadIdFromHeaders } from "./codex-session-names.mjs"; +import { applyAimlapiAttributionHeaders } from "./aimlapi-attribution.mjs"; import { applyOpenCodeSessionHeaders, isOpenCodeProvider } from "./opencode-session.mjs"; import { clampOpenCodeMessageContent } from "./opencode-message-compat.mjs"; import { @@ -1582,6 +1583,9 @@ function upstreamHeaders(requestHeaders, body, apiKey, provider, extraHeaders = requestHeaders, body, }); + // Keyed on where the request actually goes, so a baseUrl override that moves + // a provider onto -- or off -- the AI/ML API gateway moves attribution with it. + applyAimlapiAttributionHeaders(headers, { endpoint }); // Content-Length is fetch's to compute. An explicit copy is at best // redundant, and the HTTP/1.1 dispatcher rejects the request outright // (UND_ERR_INVALID_ARG) when a caller-supplied value accompanies a body. diff --git a/src/model-discovery.mjs b/src/model-discovery.mjs index 14d2e2ef..b05567bb 100644 --- a/src/model-discovery.mjs +++ b/src/model-discovery.mjs @@ -22,6 +22,7 @@ import { RUNTIME_PROVIDERS, resolveProviderBaseUrl, } from "./model-registry.mjs"; +import { applyAimlapiAttributionHeaders } from "./aimlapi-attribution.mjs"; import { curatedModelBlockReason } from "./opencode-curation.mjs"; import { OPENCODE_SESSION_FALLBACKS, @@ -334,6 +335,7 @@ async function providerPayload(provider, identity) { fallback: OPENCODE_SESSION_FALLBACKS.discovery, }); } + applyAimlapiAttributionHeaders(headers, { baseUrl }); return fetchUntrustedModelCatalog(`${baseUrl}/models`, { headers, allowPrivate: Boolean(provider.keyless), diff --git a/src/provider-account-usage.mjs b/src/provider-account-usage.mjs index 0c8aca7e..cc209480 100644 --- a/src/provider-account-usage.mjs +++ b/src/provider-account-usage.mjs @@ -780,6 +780,7 @@ const QWEN_PLAN_DASHBOARD_URL = const OLLAMA_DASHBOARD_URL = "https://ollama.com/settings"; const COMMANDCODE_DASHBOARD_URL = "https://commandcode.ai/studio"; const OPENROUTER_DASHBOARD_URL = "https://openrouter.ai/settings/credits"; +const AIMLAPI_DASHBOARD_URL = "https://aimlapi.com/app/billing"; const VENICE_DASHBOARD_URL = "https://venice.ai/settings/api"; // Nous publishes no credits or usage route on the inference API (a 404 on both // /v1/credits and /v1/key), so the portal page is the only honest destination. @@ -967,6 +968,68 @@ async function veniceAccount(fetchImpl) { return account; } +// AI/ML API's GET /v1/key answers the calling key itself: its scopes, whether +// it is disabled, an optional spend `limit`, and month-to-date usage. It is a +// spend report, not a wallet -- there is no remaining balance to read -- so the +// money goes in the message rather than into a `balance` metric that would +// claim to be what is left. A numeric `limit` does make a real quota card. +export function aimlapiKeyMetrics(payload) { + const data = payload?.data; + const spent = numberValue(data?.monthly_usage_usd); + const limit = numberValue(data?.limit); + if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(spent)) return []; + const metric = quotaMetric("Monthly spend", { limit, used: spent }, "USD"); + return metric ? [metric] : []; +} + +async function aimlapiAccount(fetchImpl) { + const provider = PROVIDERS.get("aimlapi"); + const credential = resolveProviderCredential(provider); + if (!credential) return { status: "not-configured", source: "official-api", metrics: [] }; + const fallback = (message) => ({ + ...withHeaderQuota("aimlapi", localOnly(message)), + dashboardUrl: AIMLAPI_DASHBOARD_URL, + }); + const baseURL = (process.env[provider.baseUrlEnv] || provider.baseUrl).replace(/\/+$/, ""); + if (new URL(baseURL).origin !== "https://api.aimlapi.com") { + return fallback("Account usage is unavailable for a custom AI/ML API endpoint"); + } + let key; + try { + key = await requestJson(`${baseURL}/key`, credential.value, {}, fetchImpl); + } catch { + return fallback("AI/ML API account usage is unavailable; showing router traffic"); + } + const metrics = aimlapiKeyMetrics(key); + const spent = numberValue(key?.data?.monthly_usage_usd); + const spentText = Number.isFinite(spent) + ? `Spent $${spent.toFixed(2)} this month` + : undefined; + if (key?.data?.disabled === true) { + return { + ...fallback("This AI/ML API key is disabled; re-enable it or create a new one"), + metrics, + ...(metrics.length ? { status: "available", source: "official-api" } : {}), + }; + } + if (!metrics.length) { + // An uncapped key has no percentage to show, so the honest card is router + // traffic plus the month-to-date figure in words. + return fallback( + spentText + ? `${spentText}. This key is uncapped, so there is no limit to show.` + : "AI/ML API reports usage only on the billing page; showing router traffic", + ); + } + return { + status: "available", + source: "official-api", + metrics, + dashboardUrl: AIMLAPI_DASHBOARD_URL, + ...(spentText ? { message: spentText } : {}), + }; +} + async function openRouterAccount(fetchImpl) { const provider = PROVIDERS.get("openrouter"); const credential = resolveProviderCredential(provider); @@ -1072,6 +1135,7 @@ async function accountUsageFor(providerId, fetchImpl) { } if (providerId === "commandcode") return await commandCodeAccount(fetchImpl); if (providerId === "venice") return await veniceAccount(fetchImpl); + if (providerId === "aimlapi") return await aimlapiAccount(fetchImpl); if (providerId === "openrouter") return await openRouterAccount(fetchImpl); if (providerId === "nousresearch") { // Nous Portal shows credits and the subscription tier only in the diff --git a/test/aimlapi-attribution.test.mjs b/test/aimlapi-attribution.test.mjs new file mode 100644 index 00000000..0352ab77 --- /dev/null +++ b/test/aimlapi-attribution.test.mjs @@ -0,0 +1,205 @@ +// AI/ML API counts a routed request toward Codex Router only when it carries +// the attribution headers, and it must never hand them to anyone else. The +// gate is the destination host, so these tests are written against the real +// registry entry and the real baseUrl override, not a hand-made descriptor: +// what is being held is "the shipped aimlapi provider resolves to the gateway, +// and an override moves attribution with it". +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AIMLAPI_SOURCE, + AIMLAPI_PARTNER_ID, + aimlapiAttributionHeaders, + applyAimlapiAttributionHeaders, + endpointDestination, + isAimlapiBaseUrl, +} from "../src/aimlapi-attribution.mjs"; +import { PROVIDERS } from "../src/model-registry.mjs"; + +test("the host gate matches api.aimlapi.com exactly", () => { + for (const url of [ + "https://api.aimlapi.com/v1", + "https://api.aimlapi.com", + "https://API.AIMLAPI.COM/v1", + ]) assert.equal(isAimlapiBaseUrl(url), true, url); + + for (const url of [ + // The suffix cases are the whole reason this is an equality test. + "https://api.aimlapi.com.example.net/v1", + "https://notapi.aimlapi.com/v1", + "https://aimlapi.com/v1", + "https://openrouter.ai/api/v1", + "http://127.0.0.1:1234/v1", + "api.aimlapi.com/v1", + "", + undefined, + null, + ]) assert.equal(isAimlapiBaseUrl(url), false, String(url)); +}); + +test("the shipped AI/ML API provider attributes its traffic", () => { + const provider = PROVIDERS.get("aimlapi"); + assert.ok(provider, "the registry must ship an aimlapi provider"); + assert.equal(endpointDestination(provider, {}), "https://api.aimlapi.com/v1"); + + const headers = { "User-Agent": "codex-router/test" }; + assert.equal(applyAimlapiAttributionHeaders(headers, { endpoint: provider, env: {} }), true); + assert.equal(headers["X-AIMLAPI-Source"], AIMLAPI_SOURCE); + assert.equal(headers["X-Title"], "Codex Router"); + assert.equal(headers["HTTP-Referer"], "https://github.com/duolahypercho/codex-router"); + // An unregistered partner id is silently ignored upstream, so an empty one is + // sent as nothing at all rather than as a value that looks like it works. + if (AIMLAPI_PARTNER_ID) { + assert.match(AIMLAPI_PARTNER_ID, /^part_[A-Za-z0-9]{1,64}$/); + assert.equal(headers["X-AIMLAPI-Partner-ID"], AIMLAPI_PARTNER_ID); + } else { + assert.equal("X-AIMLAPI-Partner-ID" in headers, false); + } +}); + +test("attribution follows the destination, not the provider id", () => { + const aimlapi = PROVIDERS.get("aimlapi"); + const openrouter = PROVIDERS.get("openrouter"); + + // Moved off the gateway by its own override: no attribution to a host that + // is not us. + const moved = {}; + assert.equal( + applyAimlapiAttributionHeaders(moved, { + endpoint: aimlapi, + env: { AIMLAPI_API_BASE_URL: "https://proxy.example.com/v1" }, + }), + false, + ); + assert.deepEqual(moved, {}); + + // Another provider pointed at the gateway is our traffic and is attributed. + const arrived = {}; + assert.equal( + applyAimlapiAttributionHeaders(arrived, { + endpoint: openrouter, + env: { OPENROUTER_API_BASE_URL: "https://api.aimlapi.com/v1" }, + }), + true, + ); + assert.equal(arrived["X-AIMLAPI-Source"], AIMLAPI_SOURCE); + + // Left alone, OpenRouter gets nothing. + const untouched = {}; + assert.equal( + applyAimlapiAttributionHeaders(untouched, { endpoint: openrouter, env: {} }), + false, + ); + assert.deepEqual(untouched, {}); +}); + +test("no other provider in the registry resolves onto the gateway", () => { + const attributed = [...PROVIDERS.values()] + .filter((provider) => isAimlapiBaseUrl(endpointDestination(provider, {}))) + .map((provider) => provider.id); + assert.deepEqual(attributed, ["aimlapi"]); +}); + +test("the header set is fixed", () => { + const expected = ["HTTP-Referer", "X-AIMLAPI-Source", "X-Title"]; + if (AIMLAPI_PARTNER_ID) expected.push("X-AIMLAPI-Partner-ID"); + assert.deepEqual(Object.keys(aimlapiAttributionHeaders()).sort(), expected.sort()); +}); + +// Everything above tests the decision. This tests the seam: a real forwarder +// process, the shipped registry entry, and a real HTTP request. It is pointed +// at a local upstream because that is the only destination a test can own -- +// which makes it the negative half by construction, and that is the half worth +// automating: a header that leaks to whatever host an override names is the +// failure that would go unnoticed. The positive half is verified live against +// the gateway (see the commit message). +import { spawn } from "node:child_process"; +import http from "node:http"; +import { mkdtempSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { openPort } from "./port-pool.mjs"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const INTERNAL_KEY = "test-internal-service-key-with-sufficient-length"; + +test("a routed AI/ML API model reaches its upstream, and an override takes attribution with it", async () => { + const seen = []; + const upstream = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + seen.push({ + url: request.url, + headers: request.headers, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ + id: "chatcmpl-test", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + })); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + + const port = await openPort(); + const forwarder = spawn(process.execPath, [path.join(ROOT, "src", "api-forwarder.mjs")], { + cwd: ROOT, + env: { + ...process.env, + CODEX_ROUTER_INTERNAL_KEY: INTERNAL_KEY, + CODEX_ROUTER_API_PORT: String(port), + MODEL_ROUTER_STATE_DIR: mkdtempSync(path.join(os.tmpdir(), "aimlapi-attribution-")), + CODEX_ROUTER_SHOW_ALL_MODELS: "1", + CODEX_ROUTER_QUIET: "1", + AIMLAPI_API_BASE_URL: `http://127.0.0.1:${upstream.address().port}`, + AIMLAPI_API_KEY: "TEST_AIMLAPI_API_KEY", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + forwarder.stderr.setEncoding("utf8"); + let errors = ""; + forwarder.stderr.on("data", (chunk) => { errors += chunk; }); + + try { + const deadline = Date.now() + 10_000; + for (;;) { + if (forwarder.exitCode !== null) throw new Error(`forwarder exited: ${errors}`); + try { + const health = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { Authorization: `Bearer ${INTERNAL_KEY}` }, + }); + if (health.ok) break; + } catch { + // not listening yet + } + if (Date.now() > deadline) throw new Error(`forwarder never came up: ${errors}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: `Bearer ${INTERNAL_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "aimlapi-gpt-6-sol", + messages: [{ role: "user", content: "hi" }], + }), + }); + assert.equal(response.status, 200, errors); + + assert.equal(seen.length, 1); + // The registry entry routes, and it sends the gateway's own model id. + assert.equal(seen[0].body.model, "openai/gpt-6-sol"); + assert.equal(seen[0].headers.authorization, "Bearer TEST_AIMLAPI_API_KEY"); + // Not the gateway, so not a word about who we are. + for (const name of ["x-aimlapi-source", "x-aimlapi-partner-id", "x-title", "http-referer"]) { + assert.equal(name in seen[0].headers, false, `${name} leaked to a non-gateway host`); + } + } finally { + forwarder.kill("SIGTERM"); + await new Promise((resolve) => forwarder.once("exit", resolve)); + await new Promise((resolve) => upstream.close(resolve)); + } +}); diff --git a/test/provider-catalogs.test.mjs b/test/provider-catalogs.test.mjs index d1d7a432..98036cc4 100644 --- a/test/provider-catalogs.test.mjs +++ b/test/provider-catalogs.test.mjs @@ -11,8 +11,8 @@ import { test("every selectable provider remains a canonical UI family", () => { const canonical = [...PROVIDERS.values()].filter((provider) => !provider.variantOf); - assert.equal(canonical.length, 44); - assert.equal(PROVIDERS.size, 52); + assert.equal(canonical.length, 45); + assert.equal(PROVIDERS.size, 53); }); test("catalog capability comes from backend provider definitions", () => { diff --git a/test/registry.test.mjs b/test/registry.test.mjs index 552f2e33..94bc5138 100644 --- a/test/registry.test.mjs +++ b/test/registry.test.mjs @@ -34,6 +34,16 @@ test("provider registry exposes configured API and OAuth model families", () => assert.deepEqual( LISTED_MODELS.map((model) => model.slug), [ + "aimlapi/claude-opus-5.5", + "aimlapi/claude-sonnet-5", + "aimlapi/deepseek-v4.1-flash", + "aimlapi/gemini-3.8-flash", + "aimlapi/glm-5.2", + "aimlapi/gpt-6-luna", + "aimlapi/gpt-6-sol", + "aimlapi/grok-4.7", + "aimlapi/kimi-k3", + "aimlapi/qwen3.7-max", "ainetcafe/kimi-k3", "anthropic-api/claude-opus-4.8", "antigravity-oauth/gemini-3.1-pro", From 618f1836c25be5c9ce2f627f8b63a29606616adb Mon Sep 17 00:00:00 2001 From: Stan Date: Wed, 23 Sep 2026 14:22:18 +0500 Subject: [PATCH 2/4] fix: fill in the registered AI/ML API partner id Registered as partnerName `codexrouter`. Verified through a real forwarder process: the outgoing request carries X-AIMLAPI-Partner-ID with this value, and a live route still answers. --- src/aimlapi-attribution.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aimlapi-attribution.mjs b/src/aimlapi-attribution.mjs index 74049887..761340a9 100644 --- a/src/aimlapi-attribution.mjs +++ b/src/aimlapi-attribution.mjs @@ -19,7 +19,7 @@ export const AIMLAPI_SOURCE = "agent/codex-router"; // Minted by AI/ML API, not by us. Empty means "not registered yet": an unknown // partner id is accepted and silently dropped upstream, so sending a // placeholder would look identical to working and count nothing. -export const AIMLAPI_PARTNER_ID = ""; +export const AIMLAPI_PARTNER_ID = "part_iHUWvDUArvZvhexX3PzGBwPS"; const REFERER = "https://github.com/duolahypercho/codex-router"; const TITLE = "Codex Router"; From bdbd837b0d90ba66c4c02161780976c671de8f35 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 1 Oct 2026 14:36:58 +0500 Subject: [PATCH 3/4] fix: keep stepfun last in the SVG asset list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray icon commit appended "aimlapi" after "stepfun" in the extension array, which broke test/provider-branding.test.mjs: it asserts the list ends with `"stepfun"].contains(assetName`. The array is an unordered set, so the new entry goes first and the upstream assertion holds unchanged rather than being edited to accommodate us. My own regression, and my own filtered test output is why it went unnoticed: the summary grep also matched every passing "✔ fails ..." line, so the real counters never reached the terminal. --- apps/macos/ModelRouterTray/Sources/IslandOverlay.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift b/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift index 9f75ba0e..634fa59c 100644 --- a/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift +++ b/apps/macos/ModelRouterTray/Sources/IslandOverlay.swift @@ -1105,7 +1105,7 @@ struct ProviderIcon: View { private var assetExtension: String { // Keyed off the asset, not the provider id, so every route sharing a mark // (opencode-go and friends) resolves the same file type. - ["github-copilot", "chutes", "google", "opencode-free", "kilo-free", "nano-gpt", "stepfun", "aimlapi"] + ["aimlapi", "github-copilot", "chutes", "google", "opencode-free", "kilo-free", "nano-gpt", "stepfun"] .contains(assetName ?? "") ? "svg" : "png" } From 1d7f5758c1aaa8c728e18a388e7c2b6bb6776296 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 1 Oct 2026 14:36:58 +0500 Subject: [PATCH 4/4] feat: add the two AI/ML API flagships that shipped since this branch openai/gpt-6.1-sol (2026-09-29) and anthropic/claude-sonnet-5.5 (2026-09-28) are both marked hottest on the gateway and both supersede routes already here. Their predecessors stay listed: all twelve upstream ids are still live, so this adds rather than replaces, and nobody's pinned route breaks. Measured 2026-10-01 the same way as the original ten, with a 16k output ceiling so a thinking budget never reads as a rejected effort: - gpt-6.1-sol: low, medium, high (same ladder as gpt-6-sol), text and image - claude-sonnet-5.5: low through max, text and image Both make tool calls, and both answered 200 through a real forwarder process. Priorities renumbered 1-12 so the current flagship leads the picker in this fork; upstream would want these moved into a free range instead. Full suite: 4610 tests, 0 failing. --- README.md | 2 + config/aimlapi/claude-opus-5.5.json | 2 +- config/aimlapi/claude-sonnet-5.5.json | 45 +++++++++++++++++++ config/aimlapi/claude-sonnet-5.json | 2 +- config/aimlapi/deepseek-v4.1-flash.json | 2 +- config/aimlapi/gemini-3.8-flash.json | 2 +- config/aimlapi/glm-5.2.json | 2 +- config/aimlapi/gpt-6-luna.json | 2 +- config/aimlapi/gpt-6-sol.json | 2 +- config/aimlapi/gpt-6.1-sol.json | 37 +++++++++++++++ config/aimlapi/grok-4.7.json | 2 +- config/aimlapi/kimi-k3.json | 2 +- config/aimlapi/qwen3.7-max.json | 2 +- .../src/content/docs/providers/overview.md | 2 + test/registry.test.mjs | 2 + 15 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 config/aimlapi/claude-sonnet-5.5.json create mode 100644 config/aimlapi/gpt-6.1-sol.json diff --git a/README.md b/README.md index 4b8a5f27..afa59ee1 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,11 @@ Linux installations support the Codex CLI. | Picker label | Model ID | Authentication | | --- | --- | --- | +| GPT-6.1 Sol (AI/ML API) | `aimlapi/gpt-6.1-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | | GPT-6 Sol (AI/ML API) | `aimlapi/gpt-6-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | | GPT-6 Luna (AI/ML API) | `aimlapi/gpt-6-luna` | AI/ML API key (`AIMLAPI_API_KEY`) | | Claude Opus 5.5 (AI/ML API) | `aimlapi/claude-opus-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Sonnet 5.5 (AI/ML API) | `aimlapi/claude-sonnet-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | | Claude Sonnet 5 (AI/ML API) | `aimlapi/claude-sonnet-5` | AI/ML API key (`AIMLAPI_API_KEY`) | | Gemini 3.8 Flash (AI/ML API) | `aimlapi/gemini-3.8-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | | DeepSeek V4.1 Flash (AI/ML API) | `aimlapi/deepseek-v4.1-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | diff --git a/config/aimlapi/claude-opus-5.5.json b/config/aimlapi/claude-opus-5.5.json index 9c015203..10fe1316 100644 --- a/config/aimlapi/claude-opus-5.5.json +++ b/config/aimlapi/claude-opus-5.5.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Claude Opus 5.5 (AI/ML API)", "description": "Anthropic Claude Opus 5.5 through AI/ML API's OpenAI-compatible surface; the full effort ladder up to max.", - "priority": 3, + "priority": 4, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/claude-sonnet-5.5.json b/config/aimlapi/claude-sonnet-5.5.json new file mode 100644 index 00000000..18f914ab --- /dev/null +++ b/config/aimlapi/claude-sonnet-5.5.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/claude-sonnet-5.5", + "gatewayModel": "aimlapi-claude-sonnet-5-5", + "upstreamModel": "anthropic/claude-sonnet-5.5", + "provider": "aimlapi", + "listed": true, + "displayName": "Claude Sonnet 5.5 (AI/ML API)", + "description": "Anthropic Claude Sonnet 5.5 through AI/ML API; the current Sonnet, full effort ladder up to max.", + "priority": 5, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + }, + { + "effort": "xhigh", + "description": "Extra deep reasoning" + }, + { + "effort": "max", + "description": "Maximum reasoning depth" + } + ], + "contextWindow": 1000000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-claude-sonnet-5-5-v1" + } + ] +} diff --git a/config/aimlapi/claude-sonnet-5.json b/config/aimlapi/claude-sonnet-5.json index be874367..3cf2ca70 100644 --- a/config/aimlapi/claude-sonnet-5.json +++ b/config/aimlapi/claude-sonnet-5.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Claude Sonnet 5 (AI/ML API)", "description": "Anthropic Claude Sonnet 5 through AI/ML API; balanced coding model with the full effort ladder.", - "priority": 4, + "priority": 6, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/deepseek-v4.1-flash.json b/config/aimlapi/deepseek-v4.1-flash.json index 62a87fd3..4bafa304 100644 --- a/config/aimlapi/deepseek-v4.1-flash.json +++ b/config/aimlapi/deepseek-v4.1-flash.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "DeepSeek V4.1 Flash (AI/ML API)", "description": "DeepSeek V4.1 Flash through AI/ML API; fast reasoning with tool calls and image understanding.", - "priority": 6, + "priority": 8, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/gemini-3.8-flash.json b/config/aimlapi/gemini-3.8-flash.json index 96299809..49b1bb8d 100644 --- a/config/aimlapi/gemini-3.8-flash.json +++ b/config/aimlapi/gemini-3.8-flash.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Gemini 3.8 Flash (AI/ML API)", "description": "Google Gemini 3.8 Flash through AI/ML API; 1M context, text and image input. Rejects xhigh, accepts max.", - "priority": 5, + "priority": 7, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/glm-5.2.json b/config/aimlapi/glm-5.2.json index 600e6498..fcd4d93a 100644 --- a/config/aimlapi/glm-5.2.json +++ b/config/aimlapi/glm-5.2.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "GLM 5.2 (AI/ML API)", "description": "Zhipu GLM 5.2 through AI/ML API; text only on this route, and the only model here that accepts every effort level.", - "priority": 7, + "priority": 9, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/gpt-6-luna.json b/config/aimlapi/gpt-6-luna.json index b1c1f97e..2bf30f95 100644 --- a/config/aimlapi/gpt-6-luna.json +++ b/config/aimlapi/gpt-6-luna.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "GPT-6 Luna (AI/ML API)", "description": "OpenAI GPT-6 Luna through AI/ML API; the fast, cost-efficient GPT-6 for high-volume work, 1.05M context.", - "priority": 2, + "priority": 3, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/gpt-6-sol.json b/config/aimlapi/gpt-6-sol.json index 89c24e61..765bad18 100644 --- a/config/aimlapi/gpt-6-sol.json +++ b/config/aimlapi/gpt-6-sol.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "GPT-6 Sol (AI/ML API)", "description": "OpenAI GPT-6 Sol through AI/ML API; built for complex coding and agentic work, 1.05M context, text and image input.", - "priority": 1, + "priority": 2, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/gpt-6.1-sol.json b/config/aimlapi/gpt-6.1-sol.json new file mode 100644 index 00000000..42e593f6 --- /dev/null +++ b/config/aimlapi/gpt-6.1-sol.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "models": [ + { + "slug": "aimlapi/gpt-6.1-sol", + "gatewayModel": "aimlapi-gpt-6-1-sol", + "upstreamModel": "openai/gpt-6.1-sol", + "provider": "aimlapi", + "listed": true, + "displayName": "GPT-6.1 Sol (AI/ML API)", + "description": "OpenAI GPT-6.1 Sol through AI/ML API; the current coding and agentic flagship, 1.05M context, text and image input.", + "priority": 1, + "defaultEffort": "high", + "reasoningLevels": [ + { + "effort": "low", + "description": "Faster reasoning" + }, + { + "effort": "medium", + "description": "Balanced reasoning" + }, + { + "effort": "high", + "description": "Deep reasoning" + } + ], + "contextWindow": 1050000, + "autoCompact": 900000, + "inputModalities": [ + "text", + "image" + ], + "compHash": "aimlapi-gpt-6-1-sol-v1" + } + ] +} diff --git a/config/aimlapi/grok-4.7.json b/config/aimlapi/grok-4.7.json index f735886b..d49f329d 100644 --- a/config/aimlapi/grok-4.7.json +++ b/config/aimlapi/grok-4.7.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Grok 4.7 (AI/ML API)", "description": "xAI Grok 4.7 through AI/ML API; text only on this route, full effort ladder.", - "priority": 9, + "priority": 11, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/kimi-k3.json b/config/aimlapi/kimi-k3.json index a8bb4b69..c62ed481 100644 --- a/config/aimlapi/kimi-k3.json +++ b/config/aimlapi/kimi-k3.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Kimi K3 (AI/ML API)", "description": "Moonshot Kimi K3 through AI/ML API; 1M context with image input. Accepts low, high and max only.", - "priority": 8, + "priority": 10, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/config/aimlapi/qwen3.7-max.json b/config/aimlapi/qwen3.7-max.json index 24559486..5fe9ba4a 100644 --- a/config/aimlapi/qwen3.7-max.json +++ b/config/aimlapi/qwen3.7-max.json @@ -9,7 +9,7 @@ "listed": true, "displayName": "Qwen3.7 Max (AI/ML API)", "description": "Alibaba Qwen3.7 Max through AI/ML API; text only. Its thinking budget is large, so medium effort needs an output ceiling above 32k tokens.", - "priority": 10, + "priority": 12, "defaultEffort": "high", "reasoningLevels": [ { diff --git a/docs-site/src/content/docs/providers/overview.md b/docs-site/src/content/docs/providers/overview.md index 1d637f58..be9ddd54 100644 --- a/docs-site/src/content/docs/providers/overview.md +++ b/docs-site/src/content/docs/providers/overview.md @@ -4,9 +4,11 @@ description: "Connect provider access without putting secrets in shell history." --- | Picker label | Model ID | Authentication | | --- | --- | --- | +| GPT-6.1 Sol (AI/ML API) | `aimlapi/gpt-6.1-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | | GPT-6 Sol (AI/ML API) | `aimlapi/gpt-6-sol` | AI/ML API key (`AIMLAPI_API_KEY`) | | GPT-6 Luna (AI/ML API) | `aimlapi/gpt-6-luna` | AI/ML API key (`AIMLAPI_API_KEY`) | | Claude Opus 5.5 (AI/ML API) | `aimlapi/claude-opus-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | +| Claude Sonnet 5.5 (AI/ML API) | `aimlapi/claude-sonnet-5.5` | AI/ML API key (`AIMLAPI_API_KEY`) | | Claude Sonnet 5 (AI/ML API) | `aimlapi/claude-sonnet-5` | AI/ML API key (`AIMLAPI_API_KEY`) | | Gemini 3.8 Flash (AI/ML API) | `aimlapi/gemini-3.8-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | | DeepSeek V4.1 Flash (AI/ML API) | `aimlapi/deepseek-v4.1-flash` | AI/ML API key (`AIMLAPI_API_KEY`) | diff --git a/test/registry.test.mjs b/test/registry.test.mjs index 94bc5138..2792005f 100644 --- a/test/registry.test.mjs +++ b/test/registry.test.mjs @@ -35,12 +35,14 @@ test("provider registry exposes configured API and OAuth model families", () => LISTED_MODELS.map((model) => model.slug), [ "aimlapi/claude-opus-5.5", + "aimlapi/claude-sonnet-5.5", "aimlapi/claude-sonnet-5", "aimlapi/deepseek-v4.1-flash", "aimlapi/gemini-3.8-flash", "aimlapi/glm-5.2", "aimlapi/gpt-6-luna", "aimlapi/gpt-6-sol", + "aimlapi/gpt-6.1-sol", "aimlapi/grok-4.7", "aimlapi/kimi-k3", "aimlapi/qwen3.7-max",