diff --git a/changelog.d/fixes/7942-spark-openai-compatible-endpoint.md b/changelog.d/fixes/7942-spark-openai-compatible-endpoint.md new file mode 100644 index 0000000000..09384e14a0 --- /dev/null +++ b/changelog.d/fixes/7942-spark-openai-compatible-endpoint.md @@ -0,0 +1 @@ +- **fix(providers):** iFlytek Spark (`iflytek`, `sparkdesk`) now target the OpenAI-compatible host `spark-api-open.xf-yun.com/v1`; the previous `spark-api.xf-yun.com` is the WebSocket host and rejects bearer auth ([#7942](https://github.com/diegosouzapw/OmniRoute/pull/7942)) — thanks @FenjuFu diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 57edc9efc1..5ee692d7e6 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -443,7 +443,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "siliconflow", modelId: "zai-org/GLM-4.7", displayName: "GLM 4.7", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "siliconflow", tos: "caution" }, { provider: "siliconflow", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "siliconflow", tos: "caution" }, { provider: "siliconflow", modelId: "baidu/ERNIE-4.5-300B-A47B", displayName: "ERNIE 4.5 300B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "siliconflow", tos: "caution" }, - { provider: "sparkdesk", modelId: "general", displayName: "General", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "sparkdesk", tos: "caution" }, + { provider: "sparkdesk", modelId: "lite", displayName: "Spark Lite", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "sparkdesk", tos: "caution" }, { provider: "stepfun", modelId: "step-1v", displayName: "Step 1V", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "stepfun", tos: "ok" }, { provider: "t3-web", modelId: "claude-opus-4", displayName: "Claude Opus 4 (via t3.chat)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "t3-web", tos: "avoid" }, { provider: "t3-web", modelId: "claude-sonnet-4", displayName: "Claude Sonnet 4 (via t3.chat)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "t3-web", tos: "avoid" }, diff --git a/open-sse/config/providers/registry/iflytek/index.ts b/open-sse/config/providers/registry/iflytek/index.ts index 31d31e5b53..747cd57c92 100644 --- a/open-sse/config/providers/registry/iflytek/index.ts +++ b/open-sse/config/providers/registry/iflytek/index.ts @@ -5,11 +5,15 @@ export const iflytekProvider: RegistryEntry = { alias: "iflytek", format: "openai", executor: "default", - baseUrl: "https://spark-api.xf-yun.com/v1/chat/completions", + baseUrl: "https://spark-api-open.xf-yun.com/v1/chat/completions", authType: "apikey", authHeader: "bearer", - // Sweep 2026-06-19: confirmed exact HTTP `domain` values against the official - // xfyun.cn Spark docs. generalv3.5 = Max (current); 4.0Ultra is case-sensitive. + // `spark-api-open.xf-yun.com` is Spark's OpenAI-compatible HTTP endpoint (Bearer + // APIPassword). `spark-api.xf-yun.com` is the WebSocket host — `wss://.../v3.1/chat` + // etc., authenticated with an HMAC-SHA256 signature, not a bearer token — so it + // cannot serve this `format: "openai"` / `authHeader: "bearer"` entry. + // Model ids are the `domain` values accepted by the HTTP endpoint: + // generalv3.5 = Max (current); 4.0Ultra is case-sensitive. models: [ { id: "4.0Ultra", name: "Spark 4.0 Ultra", contextLength: 32768 }, { id: "generalv3.5", name: "Spark Max (V3.5)" }, diff --git a/open-sse/config/providers/registry/sparkdesk/index.ts b/open-sse/config/providers/registry/sparkdesk/index.ts index c16edf8321..4df1fa1150 100644 --- a/open-sse/config/providers/registry/sparkdesk/index.ts +++ b/open-sse/config/providers/registry/sparkdesk/index.ts @@ -5,16 +5,19 @@ export const sparkdeskProvider: RegistryEntry = { alias: "sparkdesk", format: "openai", executor: "default", - baseUrl: "https://spark-api.xf-yun.com/v3.1/chat/completions", + baseUrl: "https://spark-api-open.xf-yun.com/v1/chat/completions", authType: "apikey", authHeader: "bearer", - // Sweep 2026-06-19: confirmed exact HTTP `domain` values against the official - // xfyun.cn Spark docs. `spark-x` was rejected — it lives on a separate /v2 (X1.5) / - // /x2 (X2) endpoint and would 404 on this /v3.1 base. + // `spark-api-open.xf-yun.com` is Spark's OpenAI-compatible HTTP endpoint (Bearer + // APIPassword). `spark-api.xf-yun.com` is the WebSocket host — `wss://.../v3.1/chat` + // etc., authenticated with an HMAC-SHA256 signature, not a bearer token — so it + // cannot serve this `format: "openai"` / `authHeader: "bearer"` entry. + // Model ids are the `domain` values accepted by the HTTP endpoint. `spark-x` is + // rejected: it lives on separate /v2 (X1.5) and /x2 (X2) endpoints. models: [ { id: "4.0Ultra", name: "Spark 4.0 Ultra", contextLength: 32768 }, { id: "generalv3", name: "Spark Pro", contextLength: 8192 }, { id: "pro-128k", name: "Spark Pro 128K", contextLength: 131072 }, - { id: "general", name: "General" }, + { id: "lite", name: "Spark Lite", contextLength: 4096 }, ], }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index eb80e2ae72..1c89474e4d 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2476,8 +2476,8 @@ } }, "url": { - "nonStream": "https://spark-api.xf-yun.com/v1/chat/completions", - "stream": "https://spark-api.xf-yun.com/v1/chat/completions" + "nonStream": "https://spark-api-open.xf-yun.com/v1/chat/completions", + "stream": "https://spark-api-open.xf-yun.com/v1/chat/completions" } }, "inference-net": { @@ -4152,8 +4152,8 @@ } }, "url": { - "nonStream": "https://spark-api.xf-yun.com/v3.1/chat/completions", - "stream": "https://spark-api.xf-yun.com/v3.1/chat/completions" + "nonStream": "https://spark-api-open.xf-yun.com/v1/chat/completions", + "stream": "https://spark-api-open.xf-yun.com/v1/chat/completions" } }, "stepfun": { diff --git a/tests/unit/spark-openai-compatible-endpoint-7942.test.ts b/tests/unit/spark-openai-compatible-endpoint-7942.test.ts new file mode 100644 index 0000000000..ec38902920 --- /dev/null +++ b/tests/unit/spark-openai-compatible-endpoint-7942.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { iflytekProvider } from "../../open-sse/config/providers/registry/iflytek/index.ts"; +import { sparkdeskProvider } from "../../open-sse/config/providers/registry/sparkdesk/index.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; + +// #7942: both entries declare format: "openai" + authHeader: "bearer", but pointed at +// spark-api.xf-yun.com — Spark's WebSocket host, which authenticates with an +// HMAC-SHA256 signature over app_id/apiKey/apiSecret and rejects bearer tokens. The +// OpenAI-compatible HTTP API lives on spark-api-open.xf-yun.com/v1. + +test("#7942: iflytek registry baseUrl uses Spark's OpenAI-compatible HTTP host", () => { + assert.equal( + iflytekProvider.baseUrl, + "https://spark-api-open.xf-yun.com/v1/chat/completions", + "iflytek must route through the OpenAI-compatible HTTP endpoint, not the " + + "WebSocket-only spark-api.xf-yun.com host (which rejects bearer auth)" + ); +}); + +test("#7942: sparkdesk registry baseUrl uses Spark's OpenAI-compatible HTTP host", () => { + assert.equal( + sparkdeskProvider.baseUrl, + "https://spark-api-open.xf-yun.com/v1/chat/completions", + "sparkdesk must route through the OpenAI-compatible HTTP endpoint, not the " + + "WebSocket-only spark-api.xf-yun.com/v3.1 host (which rejects bearer auth)" + ); +}); + +test("#7942: sparkdesk no longer advertises the WebSocket-only 'general' domain", () => { + const modelIds = sparkdeskProvider.models.map((m) => m.id); + assert.ok( + !modelIds.includes("general"), + "'general' is a WebSocket-domain value rejected by the HTTP endpoint" + ); + assert.ok( + modelIds.includes("lite"), + "sparkdesk should advertise 'lite' (Spark Lite) in place of the removed 'general' domain" + ); +}); + +test("#7942: free-model catalog's sparkdesk row references a model the registry still advertises", () => { + const sparkdeskModelIds = new Set(sparkdeskProvider.models.map((m) => m.id)); + const catalogRows = FREE_MODEL_BUDGETS.filter((row) => row.provider === "sparkdesk"); + assert.ok(catalogRows.length > 0, "expected at least one sparkdesk row in the free catalog"); + for (const row of catalogRows) { + assert.ok( + sparkdeskModelIds.has(row.modelId), + `free catalog references sparkdesk/${row.modelId}, which the registry no longer advertises` + ); + } +});