fix(providers): update web model discovery (#6308)

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
This commit is contained in:
janeza2
2026-07-10 05:54:05 +07:00
committed by GitHub
parent b9d18dd8c4
commit 9906dfc1ba
11 changed files with 163 additions and 39 deletions

View File

@@ -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<string, unknown> | null): { kind: DeltaKind; text: string } | null {
export function extractDelta(
msg: Record<string, unknown> | 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<string, unknown> | null): boolean {
if (!msg) return false;
// Assistant message flipped to COMPLETED.
const message = (msg.message ?? null) as Record<string, unknown> | 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();

View File

@@ -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<string, string>;
parseResponse: (data: any) => any;
};
@@ -60,10 +62,10 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
},
// #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<string, ProviderModelsConfigEntry> =
},
},
// #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=<JWT>`.
// 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;

View File

@@ -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<string, string>;
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 {

View File

@@ -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;
}

View File

@@ -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).
//

View File

@@ -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/"
);
});

View File

@@ -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;

View File

@@ -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<string, string>;
assert.equal(headers.Authorization, `Bearer ${jwt}`);
assert.equal(headers.Cookie, `kimi-auth=${jwt}`);
assert.equal(headers["connect-protocol-version"], "1");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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(",")}`);