diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index cf4ff4bdbe..a2fe1e6df1 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -26,7 +26,10 @@ * session; the upstream returns the same response either way. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; +import { + makeExecutorErrorResult as makeErrorResult, + sanitizeErrorMessage, +} from "../utils/error.ts"; import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export { extractKimiJwt }; @@ -93,7 +96,10 @@ const MAX_FRAME_LEN = 8 * 1024 * 1024; * (caller must treat this as a stream-fatal protocol error) * - `consumed: N` + the parsed frame otherwise */ -export function decodeConnectFrame(buf: Uint8Array, byteOffset: number): { consumed: number; frame: ConnectFrame | null } { +export function decodeConnectFrame( + buf: Uint8Array, + byteOffset: number +): { consumed: number; frame: ConnectFrame | null } { if (byteOffset + 5 > buf.length) return { consumed: 0, frame: null }; const flags = buf[byteOffset]; const len = @@ -130,7 +136,9 @@ type DeltaKind = "text" | "think" | null; * Anything else (heartbeats, chat/message metadata, stage transitions) is * suppressed; we only surface text to the client. */ -export function extractDelta(msg: Record | null): { kind: DeltaKind; text: string } | null { +export function extractDelta( + msg: Record | null +): { kind: DeltaKind; text: string } | null { if (!msg) return null; const op = String(msg.op ?? ""); const mask = String(msg.mask ?? ""); @@ -167,7 +175,11 @@ export function isEndOfStream(msg: Record | null): boolean { if (!msg) return false; // Assistant message flipped to COMPLETED. const message = (msg.message ?? null) as Record | null; - if (message && String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && String(message.role ?? "") === "assistant") { + if ( + message && + String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && + String(message.role ?? "") === "assistant" + ) { return true; } return false; @@ -252,7 +264,7 @@ export class KimiWebExecutor extends BaseExecutor { } const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; - const modelId = (bodyObj.model as string) || "kimi-default"; + const modelId = (bodyObj.model as string) || "k2d6"; // Resolve scenario + default thinking flag from the model id (catalog truth), // then honour an explicit `reasoning_effort: "none"` override from the caller. const modelConfig = resolveModelConfig(modelId); @@ -285,7 +297,12 @@ export class KimiWebExecutor extends BaseExecutor { if (!upstream.ok) { const errText = await upstream.text().catch(() => ""); - return makeErrorResult(upstream.status, `Kimi error: ${sanitizeErrorMessage(errText)}`, body, CHAT_URL); + return makeErrorResult( + upstream.status, + `Kimi error: ${sanitizeErrorMessage(errText)}`, + body, + CHAT_URL + ); } const encoder = new TextEncoder(); diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 6e8af36e05..7f7a64e595 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -3,6 +3,7 @@ import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityH import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; +import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export type ProviderModelsConfigEntry = { url: string; @@ -12,6 +13,7 @@ export type ProviderModelsConfigEntry = { authPrefix?: string; authQuery?: string; body?: unknown; + buildHeaders?: (token: string) => Record; parseResponse: (data: any) => any; }; @@ -60,10 +62,10 @@ export const PROVIDER_MODELS_CONFIG: Record = }, // #3931: qwen-web (cookie provider) was missing here, so its discovery page // showed nothing (the OAuth fallback above only fires for provider==="qwen"). - // `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent); + // `chat.qwen.ai/api/v2/models/` is public (no auth header configured/sent); // shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback. "qwen-web": { - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", headers: { "Content-Type": "application/json" }, parseResponse: (data) => { @@ -78,18 +80,34 @@ export const PROVIDER_MODELS_CONFIG: Record = }, }, // #5858 follow-up: kimi-web (cookie provider) on the international domain. - // `GetAvailableModels` returns the model list as a plain JSON envelope - // (no Connect framing on either request or response — only the chat - // completion endpoint uses the 5-byte envelope). Auth: Bearer JWT extracted - // from the `kimi-auth` cookie the user pasted. Agent variants + // `GetAvailableModels` returns the model list as a plain JSON envelope. + // Auth mirrors the web app: Bearer JWT plus `Cookie: kimi-auth=`. + // Agent variants // (`k2d6-agent*`) need a different scenario + agent fields this executor // doesn't shape, so they're filtered out. "kimi-web": { url: "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels", - method: "GET", - headers: { accept: "application/json, text/plain, */*", "Content-Type": "application/json" }, - authHeader: "Authorization", - authPrefix: "Bearer ", + method: "POST", + headers: { accept: "*/*", "Content-Type": "application/json" }, + body: {}, + buildHeaders: (token) => { + const jwt = extractKimiJwt(token); + return { + accept: "*/*", + "Content-Type": "application/json", + "connect-protocol-version": "1", + Origin: "https://www.kimi.com", + Referer: "https://www.kimi.com/", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + ...(jwt + ? { + Authorization: `Bearer ${jwt}`, + Cookie: `kimi-auth=${jwt}`, + } + : {}), + }; + }, parseResponse: (data) => { const list = (data?.availableModels || []) as Array<{ key?: string; diff --git a/src/app/api/providers/[id]/models/discoveryConfig.ts b/src/app/api/providers/[id]/models/discoveryConfig.ts index 5432cc8c32..ef6b6c4681 100644 --- a/src/app/api/providers/[id]/models/discoveryConfig.ts +++ b/src/app/api/providers/[id]/models/discoveryConfig.ts @@ -1,4 +1,5 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import type { ProviderModelsConfigEntry } from "./discovery/providerModelsConfig"; /** * Derive a models-discovery config from the provider's registry `modelsUrl` @@ -8,18 +9,9 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts * OpenAI-compatible `/v1/models` endpoint, or `undefined` when the * registry entry has no `modelsUrl`. */ -export function deriveConfigFromRegistryModelsUrl(provider: string): - | { - url: string; - method: "GET"; - headers: Record; - authHeader?: string; - authPrefix?: string; - authQuery?: string; - body?: unknown; - parseResponse: (data: any) => any; - } - | undefined { +export function deriveConfigFromRegistryModelsUrl( + provider: string +): ProviderModelsConfigEntry | undefined { const entry = getRegistryEntry(provider); if (typeof entry?.modelsUrl === "string" && entry.modelsUrl.length > 0) { return { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c862d4e508..153f2bc232 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1806,8 +1806,8 @@ export async function GET( } // Build headers - const headers = { ...config.headers }; - if (config.authHeader && !config.authQuery) { + const headers = config.buildHeaders ? config.buildHeaders(token) : { ...config.headers }; + if (!config.buildHeaders && config.authHeader && !config.authQuery) { headers[config.authHeader] = (config.authPrefix || "") + token; } diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index ef8c1fd366..2c537ac035 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -144,7 +144,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) { } // qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible -// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models` (via +// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models/` (via // addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the // outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758). // diff --git a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts index 5e40d43196..badf1a0fe7 100644 --- a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts +++ b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts @@ -66,12 +66,12 @@ test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", () ); }); -test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models", () => { +test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models/", () => { const src = fs.readFileSync(CONFIG_FILE, "utf-8"); assert.match( src, - /chat\.qwen\.ai\/api\/v2\/models/, - "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models" + /chat\.qwen\.ai\/api\/v2\/models\//, + "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models/" ); }); diff --git a/tests/unit/executor-kimi-web.test.ts b/tests/unit/executor-kimi-web.test.ts index bff69e30d6..5b3c7de203 100644 --- a/tests/unit/executor-kimi-web.test.ts +++ b/tests/unit/executor-kimi-web.test.ts @@ -9,6 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/kimi-web.ts"); +const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts"); describe("KimiWebExecutor", () => { it("can be instantiated", () => { @@ -79,6 +80,24 @@ describe("resolveModelConfig", () => { }); }); +describe("kimi-web catalog", () => { + it("lists only currently supported non-agent web models", () => { + const models = getModelsByProviderId("kimi-web"); + assert.deepEqual( + models.map((model) => ({ id: model.id, name: model.name })), + [ + { id: "k2d6", name: "K2.6 Instant" }, + { id: "k2d6-thinking", name: "K2.6 Thinking" }, + ] + ); + assert.ok(models.find((model) => model.id === "k2d6-thinking")?.supportsReasoning); + assert.ok(!models.some((model) => model.id.includes("agent"))); + assert.ok( + !models.some((model) => ["kimi-default", "kimi-k2.6", "kimi-128k"].includes(model.id)) + ); + }); +}); + describe("extractKimiJwt", () => { const { extractKimiJwt } = mod; diff --git a/tests/unit/kimi-web-models-discovery.test.ts b/tests/unit/kimi-web-models-discovery.test.ts new file mode 100644 index 0000000000..676347c9e2 --- /dev/null +++ b/tests/unit/kimi-web-models-discovery.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-web-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("kimi-web model discovery sends Kimi auth as bearer and cookie", async () => { + await resetStorage(); + const jwt = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIn0.signature"; + const connection = await providersDb.createProviderConnection({ + provider: "kimi-web", + authType: "apikey", + name: "kimi-web-discovery", + apiKey: `_ga=ignored; theme=dark; kimi-auth=${jwt}; __cf_bm=ignored`, + }); + + let captured: { url: string; init?: RequestInit } | null = null; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + captured = { url: String(url), init }; + return Response.json({ + availableModels: [ + { key: "k2d6", displayName: "K2.6 Instant" }, + { key: "k2d6-thinking", displayName: "K2.6 Thinking", thinking: true }, + { key: "k2d6-agent", displayName: "K2.6 Agent" }, + { key: "k2d6-agent-ultra", displayName: "K2.6 Agent Swarm" }, + ], + }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, "api"); + assert.deepEqual( + body.models.map((model: { id: string }) => model.id), + ["k2d6", "k2d6-thinking"] + ); + assert.equal( + captured?.url, + "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels" + ); + assert.equal(captured?.init?.method, "POST"); + assert.equal(captured?.init?.body, "{}"); + const headers = captured?.init?.headers as Record; + assert.equal(headers.Authorization, `Bearer ${jwt}`); + assert.equal(headers.Cookie, `kimi-auth=${jwt}`); + assert.equal(headers["connect-protocol-version"], "1"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index e6bcd11ed0..e31eb17601 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -117,7 +117,7 @@ test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => { test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => { assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models"); - assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models"); + assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/"); }); test("providerModelsConfig keeps the aimlapi live catalog entry", () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index e1afca203e..a97127bc32 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2766,7 +2766,7 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => { test("isSecurityBlockError: public-host redirect block is NOT a security block", () => { const publicRedirect = new SafeOutboundFetchError("Redirect blocked", { code: "REDIRECT_BLOCKED", - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", attempts: 1, status: 307, diff --git a/tests/unit/qwen-web-models-discovery-3931.test.ts b/tests/unit/qwen-web-models-discovery-3931.test.ts index 99e50840f2..3610311767 100644 --- a/tests/unit/qwen-web-models-discovery-3931.test.ts +++ b/tests/unit/qwen-web-models-discovery-3931.test.ts @@ -11,7 +11,7 @@ * streaming endpoint — is a separate upstream/stealth concern, still open.) * * Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public - * `https://chat.qwen.ai/api/v2/models` endpoint, parsing the + * `https://chat.qwen.ai/api/v2/models/` endpoint, parsing the * `{ data: { data: [{ id, name, owned_by }] } }` shape. */ import test from "node:test"; @@ -45,7 +45,7 @@ interface ModelsBody { source?: string; } -const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models"; +const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models/"; test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => { await resetStorage(); @@ -83,7 +83,11 @@ test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", assert.equal(response.status, 200); const body = (await response.json()) as ModelsBody; assert.equal(body.provider, "qwen-web"); - assert.equal(body.source, "api", "should serve the live qwen-web catalog, not local_catalog/empty"); + assert.equal( + body.source, + "api", + "should serve the live qwen-web catalog, not local_catalog/empty" + ); assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`); const ids = body.models.map((m) => m.id); assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`);