diff --git a/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md new file mode 100644 index 0000000000..cd7abc5c32 --- /dev/null +++ b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md @@ -0,0 +1 @@ +- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) diff --git a/open-sse/config/providers/registry/freeaiapikey/index.ts b/open-sse/config/providers/registry/freeaiapikey/index.ts index 2fe8a4ba5d..6a4785990d 100644 --- a/open-sse/config/providers/registry/freeaiapikey/index.ts +++ b/open-sse/config/providers/registry/freeaiapikey/index.ts @@ -5,34 +5,39 @@ export const freeaiapikeyProvider: RegistryEntry = { alias: "faik", format: "openai", executor: "default", - baseUrl: "https://freeaiapikey.com/v1/chat/completions", - modelsUrl: "https://freeaiapikey.com/v1/models", + // 2026-08-13: the apex host answers 410 `endpoint_moved` on every /v1 route and + // names its own replacement — "Please update your base_url to + // https://api.freeaiapikey.com/v1". The api. host serves /v1/models (200) and + // /v1/chat/completions (405 on GET, i.e. POST-only as expected). + baseUrl: "https://api.freeaiapikey.com/v1/chat/completions", + modelsUrl: "https://api.freeaiapikey.com/v1/models", authType: "apikey", authHeader: "bearer", defaultContextLength: 128000, + // Catalog synced 2026-08-13 against GET https://api.freeaiapikey.com/v1/models (200). + // That response carries only id/object/created/owned_by — upstream publishes no + // context window — so models added from it declare no contextLength and inherit + // `defaultContextLength` above rather than an invented figure. The two pre-existing + // contextLength values are left exactly as they were: nothing in this sweep confirms + // or refutes them, and rewriting them would be the same guesswork in reverse. models: [ - { id: "openai/gpt-5", name: "GPT-5 (via FreeAIAPIKey)", contextLength: 400000 }, { id: "openai/gpt-4o", name: "GPT-4o (via FreeAIAPIKey)" }, - { id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.4", name: "GPT-5.4 (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.5", name: "GPT-5.5 (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol (via FreeAIAPIKey)" }, { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6 (via FreeAIAPIKey)", contextLength: 1000000, }, + { id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7 (via FreeAIAPIKey)" }, + { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8 (via FreeAIAPIKey)" }, + { id: "anthropic/claude-opus-5", name: "Claude Opus 5 (via FreeAIAPIKey)" }, { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6 (via FreeAIAPIKey)", contextLength: 1000000, }, - { - id: "Alibaba/qwen3.5", - name: "Qwen 3.5 (via FreeAIAPIKey)", - contextLength: 128000, - }, - { - id: "Alibaba/qwen3-vl:235b", - name: "Qwen 3 VL 235B (via FreeAIAPIKey)", - contextLength: 128000, - }, + { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5 (via FreeAIAPIKey)" }, ], }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index b476020a95..9d6a45573b 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2082,8 +2082,8 @@ } }, "url": { - "nonStream": "https://freeaiapikey.com/v1/chat/completions", - "stream": "https://freeaiapikey.com/v1/chat/completions" + "nonStream": "https://api.freeaiapikey.com/v1/chat/completions", + "stream": "https://api.freeaiapikey.com/v1/chat/completions" } }, "freeinference": { diff --git a/tests/unit/freeaiapikey-endpoint-moved.test.ts b/tests/unit/freeaiapikey-endpoint-moved.test.ts new file mode 100644 index 0000000000..6176bcf46c --- /dev/null +++ b/tests/unit/freeaiapikey-endpoint-moved.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { freeaiapikeyProvider } from "../../open-sse/config/providers/registry/freeaiapikey/index.ts"; + +/** + * FreeAIAPIKey retired its apex-host API and moved it to a dedicated `api.` host. + * + * Live probe (2026-08-13), each paired with a control call so a network fault + * cannot be mistaken for an upstream verdict: + * + * GET https://freeaiapikey.com/v1/models → 410 + * GET https://freeaiapikey.com/v1/chat/completions → 410 + * GET https://api.freeaiapikey.com/v1/models → 200 + * GET https://api.freeaiapikey.com/v1/chat/completions → 405 (POST-only endpoint) + * GET https://api.openai.com/v1/models → 401 (control: reachable) + * GET https:///v1/models → 000 (control: unreachable) + * + * The 410 body names its own replacement, so the target host is upstream's own + * instruction rather than an inference: + * + * {"error":{"message":"This API endpoint has moved. Please update your base_url + * to https://api.freeaiapikey.com/v1 — the old endpoint on freeaiapikey.com no + * longer works.","type":"endpoint_moved","code":"endpoint_moved"}} + * + * Provider entry added in #2708. + */ +const LIVE_API_BASE = "https://api.freeaiapikey.com/v1"; + +/** + * Every model id returned by GET https://api.freeaiapikey.com/v1/models on 2026-08-13. + * The response carries only id/object/created/owned_by — upstream publishes no context + * window, so models catalogued from it declare no contextLength and inherit the entry's + * defaultContextLength rather than an invented number. + */ +const LIVE_MODEL_IDS = [ + "openai/gpt-4o", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.6-sol", + "anthropic/claude-opus-4.6", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", +]; + +test("freeaiapikey targets the live api. host (upstream 410 endpoint_moved)", () => { + assert.equal( + freeaiapikeyProvider.baseUrl, + `${LIVE_API_BASE}/chat/completions`, + "baseUrl must point at the host named in upstream's 410 endpoint_moved body" + ); + assert.equal( + freeaiapikeyProvider.modelsUrl, + `${LIVE_API_BASE}/models`, + "modelsUrl must point at the host named in upstream's 410 endpoint_moved body" + ); +}); + +test("freeaiapikey keeps no endpoint on the retired freeaiapikey.com apex host", () => { + for (const [field, url] of [ + ["baseUrl", freeaiapikeyProvider.baseUrl], + ["modelsUrl", freeaiapikeyProvider.modelsUrl], + ] as const) { + assert.ok(url, `${field} must be set`); + assert.doesNotMatch( + url, + /^https:\/\/freeaiapikey\.com\//, + `${field} still targets the apex host, which answers 410 endpoint_moved` + ); + } +}); + +test("freeaiapikey catalogs exactly the models upstream serves", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.deepEqual( + [...declared].sort(), + [...LIVE_MODEL_IDS].sort(), + "registry catalog must match the ids returned by the live /v1/models" + ); +}); + +test("freeaiapikey declares no duplicate model ids", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.equal(new Set(declared).size, declared.length, "model ids must be unique"); +}); + +test("freeaiapikey gives every catalogued model a display name", () => { + for (const model of freeaiapikeyProvider.models) { + assert.equal(typeof model.name, "string", `${model.id} must declare a name`); + assert.ok(model.name.length > 0, `${model.id} must declare a non-empty name`); + } +}); + +test("freeaiapikey keeps a provider-wide default for unpublished context windows", () => { + // Upstream reports no context windows, so the models added from its catalog carry + // no contextLength of their own; this default is what they fall back to. + assert.equal( + typeof freeaiapikeyProvider.defaultContextLength, + "number", + "entry must keep a defaultContextLength for models with no upstream-published window" + ); +});