mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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/"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
74
tests/unit/kimi-web-models-discovery.test.ts
Normal file
74
tests/unit/kimi-web-models-discovery.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(",")}`);
|
||||
|
||||
Reference in New Issue
Block a user