diff --git a/CHANGELOG.md b/CHANGELOG.md index 017091d589..03d5bc2ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.3.0] — 2026-02-23 + +> ### ✨ Feature Release — iFlow Fix, Health Check Logs Toggle, Kilocode Models & Model Deduplication +> +> Community-driven release with iFlow HMAC-SHA256 signature support, health check log management, expanded Kilocode model list, and model deduplication on the dashboard. + +### ✨ New Features + +- **Hide Health Check Logs** — New toggle in Settings → Appearance to suppress verbose `[HealthCheck]` messages from the server console. Uses a 30-second cache to minimize database reads with request coalescing for concurrent calls ([PR #111](https://github.com/diegosouzapw/OmniRoute/pull/111) by [@nyatoru](https://github.com/nyatoru)) +- **Kilocode Custom Models Endpoint** — Added `modelsUrl` support in `RegistryEntry` for providers with non-standard model endpoints. Expanded Kilocode model list from 8 to 26 models including Qwen3, GPT-5, Claude 3 Haiku, Gemini 2.5, DeepSeek V3, Llama 4, and more ([PR #115](https://github.com/diegosouzapw/OmniRoute/pull/115) by [@benzntech](https://github.com/benzntech)) + +### 🐛 Bug Fixes + +- **iFlow 406 Error** — Created dedicated `IFlowExecutor` with HMAC-SHA256 signature support (`session-id`, `x-iflow-timestamp`, `x-iflow-signature` headers). The iFlow provider was previously using the default executor which lacked the required signature headers, causing 406 errors ([#114](https://github.com/diegosouzapw/OmniRoute/issues/114)) +- **Duplicate Models in Endpoint Lists** — Filtered out parent models (`!m.parent`) from all model categorization and count logic on the Endpoint page. Provider modal lists also exclude duplicates ([PR #112](https://github.com/diegosouzapw/OmniRoute/pull/112) by [@nyatoru](https://github.com/nyatoru)) + +### 🧪 Tests + +- **IFlowExecutor Unit Tests** — 11 new test cases covering HMAC-SHA256 signature generation, header building, URL construction, body passthrough, and executor registry integration + +--- + ## [1.2.0] — 2026-02-22 > ### ✨ Feature Release — Dashboard Session Auth for Models Endpoint @@ -467,6 +489,7 @@ New environment variables: --- +[1.3.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.3.0 [1.2.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.2.0 [1.1.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.1 [1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index eb4251d295..b4c256ccea 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -212,7 +212,7 @@ export const REGISTRY: Record = { id: "iflow", alias: "if", format: "openai", - executor: "default", + executor: "iflow", baseUrl: "https://apis.iflow.cn/v1/chat/completions", authType: "oauth", authHeader: "bearer", diff --git a/open-sse/executors/iflow.ts b/open-sse/executors/iflow.ts new file mode 100644 index 0000000000..c544146280 --- /dev/null +++ b/open-sse/executors/iflow.ts @@ -0,0 +1,97 @@ +import crypto from "crypto"; +import { BaseExecutor } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; + +/** + * IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature. + * + * iFlow requires custom headers including a session ID, timestamp, + * and an HMAC-SHA256 signature for request authentication. + * Without these headers, the API returns a 406 error. + * + * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114 + */ +export class IFlowExecutor extends BaseExecutor { + constructor() { + super("iflow", PROVIDERS.iflow); + } + + /** + * Create iFlow signature using HMAC-SHA256 + * @param userAgent - User agent string + * @param sessionID - Session ID + * @param timestamp - Unix timestamp in milliseconds + * @param apiKey - API key for signing + * @returns Hex-encoded signature + */ + createIFlowSignature( + userAgent: string, + sessionID: string, + timestamp: number, + apiKey: string + ): string { + if (!apiKey) return ""; + const payload = `${userAgent}:${sessionID}:${timestamp}`; + const hmac = crypto.createHmac("sha256", apiKey); + hmac.update(payload); + return hmac.digest("hex"); + } + + /** + * Build headers with iFlow-specific HMAC-SHA256 signature. + * Includes session-id, x-iflow-timestamp, and x-iflow-signature. + */ + buildHeaders(credentials: any, stream = true) { + // Generate session ID and timestamp + const sessionID = `session-${crypto.randomUUID()}`; + const timestamp = Date.now(); + + // Get user agent from config + const userAgent = this.config.headers?.["User-Agent"] || "iFlow-Cli"; + + // Get API key (prefer apiKey, fallback to accessToken) + const apiKey = credentials.apiKey || credentials.accessToken || ""; + + // Create HMAC-SHA256 signature + const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey); + + // Build headers + const headers: Record = { + "Content-Type": "application/json", + ...this.config.headers, + "session-id": sessionID, + "x-iflow-timestamp": timestamp.toString(), + "x-iflow-signature": signature, + }; + + // Add authorization + if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + + // Add streaming header + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + /** + * Build URL for iFlow API — uses baseUrl directly. + */ + buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + return this.config.baseUrl; + } + + /** + * Transform request body (passthrough for iFlow). + */ + transformRequest(model: string, body: any, stream: boolean, credentials: any) { + return body; + } +} + +export default IFlowExecutor; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 1fe9599459..46acf3fada 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -1,6 +1,7 @@ import { AntigravityExecutor } from "./antigravity.ts"; import { GeminiCLIExecutor } from "./gemini-cli.ts"; import { GithubExecutor } from "./github.ts"; +import { IFlowExecutor } from "./iflow.ts"; import { KiroExecutor } from "./kiro.ts"; import { CodexExecutor } from "./codex.ts"; import { CursorExecutor } from "./cursor.ts"; @@ -10,6 +11,7 @@ const executors = { antigravity: new AntigravityExecutor(), "gemini-cli": new GeminiCLIExecutor(), github: new GithubExecutor(), + iflow: new IFlowExecutor(), kiro: new KiroExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), @@ -32,6 +34,7 @@ export { BaseExecutor } from "./base.ts"; export { AntigravityExecutor } from "./antigravity.ts"; export { GeminiCLIExecutor } from "./gemini-cli.ts"; export { GithubExecutor } from "./github.ts"; +export { IFlowExecutor } from "./iflow.ts"; export { KiroExecutor } from "./kiro.ts"; export { CodexExecutor } from "./codex.ts"; export { CursorExecutor } from "./cursor.ts"; diff --git a/package.json b/package.json index a35c93a0ca..20e162face 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "1.2.0", + "version": "1.3.0", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/tests/unit/iflow-executor.test.mjs b/tests/unit/iflow-executor.test.mjs new file mode 100644 index 0000000000..0da8f368ad --- /dev/null +++ b/tests/unit/iflow-executor.test.mjs @@ -0,0 +1,150 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +// ═══════════════════════════════════════════════════════════════ +// IFlowExecutor Unit Tests +// Tests for HMAC-SHA256 signature, headers, URL building +// Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114 +// ═══════════════════════════════════════════════════════════════ + +const { IFlowExecutor } = await import("../../open-sse/executors/iflow.ts"); + +// ─── Constructor ────────────────────────────────────────────── + +test("IFlowExecutor: constructor sets provider to 'iflow'", () => { + const executor = new IFlowExecutor(); + assert.equal(executor.getProvider(), "iflow"); +}); + +// ─── createIFlowSignature ───────────────────────────────────── + +test("IFlowExecutor: createIFlowSignature returns valid HMAC-SHA256 hex", () => { + const executor = new IFlowExecutor(); + const userAgent = "iFlow-Cli"; + const sessionID = "session-test-123"; + const timestamp = 1700000000000; + const apiKey = "test-api-key-secret"; + + const signature = executor.createIFlowSignature(userAgent, sessionID, timestamp, apiKey); + + // Verify it's a valid hex string (64 chars for SHA256) + assert.match(signature, /^[0-9a-f]{64}$/); + + // Verify reproducibility — same inputs produce same signature + const signature2 = executor.createIFlowSignature(userAgent, sessionID, timestamp, apiKey); + assert.equal(signature, signature2); + + // Verify against manual HMAC computation + const payload = `${userAgent}:${sessionID}:${timestamp}`; + const expected = crypto.createHmac("sha256", apiKey).update(payload).digest("hex"); + assert.equal(signature, expected); +}); + +test("IFlowExecutor: createIFlowSignature returns empty string when apiKey is empty", () => { + const executor = new IFlowExecutor(); + const result = executor.createIFlowSignature("agent", "session", 123, ""); + assert.equal(result, ""); +}); + +test("IFlowExecutor: createIFlowSignature returns empty string when apiKey is null", () => { + const executor = new IFlowExecutor(); + const result = executor.createIFlowSignature("agent", "session", 123, null); + assert.equal(result, ""); +}); + +// ─── buildHeaders ───────────────────────────────────────────── + +test("IFlowExecutor: buildHeaders includes iflow-specific headers", () => { + const executor = new IFlowExecutor(); + const credentials = { apiKey: "test-key-123" }; + + const headers = executor.buildHeaders(credentials, true); + + // Must include required iflow headers + assert.ok(headers["session-id"], "Missing session-id header"); + assert.ok(headers["x-iflow-timestamp"], "Missing x-iflow-timestamp header"); + assert.ok(headers["x-iflow-signature"], "Missing x-iflow-signature header"); + + // session-id format + assert.ok( + headers["session-id"].startsWith("session-"), + "session-id should start with 'session-'" + ); + + // timestamp is a number string + assert.match(headers["x-iflow-timestamp"], /^\d+$/); + + // signature is hex + assert.match(headers["x-iflow-signature"], /^[0-9a-f]{64}$/); + + // Authorization + assert.equal(headers["Authorization"], "Bearer test-key-123"); + + // Content-Type + assert.equal(headers["Content-Type"], "application/json"); + + // Streaming Accept + assert.equal(headers["Accept"], "text/event-stream"); +}); + +test("IFlowExecutor: buildHeaders omits Accept header when stream is false", () => { + const executor = new IFlowExecutor(); + const credentials = { apiKey: "test-key" }; + + const headers = executor.buildHeaders(credentials, false); + + assert.equal(headers["Accept"], undefined); +}); + +test("IFlowExecutor: buildHeaders uses accessToken when apiKey is missing", () => { + const executor = new IFlowExecutor(); + const credentials = { accessToken: "oauth-token-123" }; + + const headers = executor.buildHeaders(credentials); + + assert.equal(headers["Authorization"], "Bearer oauth-token-123"); + // Signature should still be generated using the accessToken + assert.ok(headers["x-iflow-signature"].length > 0); +}); + +test("IFlowExecutor: buildHeaders generates unique session IDs per call", () => { + const executor = new IFlowExecutor(); + const credentials = { apiKey: "key" }; + + const headers1 = executor.buildHeaders(credentials); + const headers2 = executor.buildHeaders(credentials); + + assert.notEqual(headers1["session-id"], headers2["session-id"]); +}); + +// ─── buildUrl ───────────────────────────────────────────────── + +test("IFlowExecutor: buildUrl returns config baseUrl", () => { + const executor = new IFlowExecutor(); + const url = executor.buildUrl("qwen3-coder-plus", true); + + assert.equal(url, "https://apis.iflow.cn/v1/chat/completions"); +}); + +// ─── transformRequest ───────────────────────────────────────── + +test("IFlowExecutor: transformRequest passes body through unchanged", () => { + const executor = new IFlowExecutor(); + const body = { + model: "deepseek-r1", + messages: [{ role: "user", content: "Hello" }], + stream: true, + }; + + const result = executor.transformRequest("deepseek-r1", body, true, {}); + assert.deepEqual(result, body); +}); + +// ─── Integration: executor registry ─────────────────────────── + +test("IFlowExecutor: getExecutor('iflow') returns IFlowExecutor instance", async () => { + const { getExecutor } = await import("../../open-sse/executors/index.ts"); + const executor = getExecutor("iflow"); + assert.ok(executor instanceof IFlowExecutor, "Should return IFlowExecutor instance"); +});