Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
# v0.7.4 (2026-07-19)

## Features
- **Forge Workspace provider**: new API-key provider (forge) with 33 models across 3 pricing tiers (free/pro/enterprise), live model discovery via modelsFetcher, dedicated pricing block, and SVG brand icon.
- **TokenRouter provider + Quota Tracker**: new API-key provider (tokenrouter) with a separate Management API key (mirrors TokenRouter's two-credential design). Quota card surfaces Wallet / Top-up / Voucher balances from the management endpoint. Handler returns a graceful `message` when no management key is configured (no more UI crash).
- **Huancheng Public API (hcnsec) provider**: new OpenAI-compatible regional API-key provider.
- **Qwen Cloud + Qwen Cloud Token Plan providers**: two new dedicated API-key providers for Alibaba Cloud's Bailian Qwen endpoints (pay-as-you-go + token-plan variants).
- **Alibaba + Alibaba CN providers**: regional Alibaba DashScope API-key providers (international + China mainland base URLs).
- **GitHub Copilot native /v1/messages routing**: Claude models routed directly to GitHub's `/messages` endpoint (targetFormat:"claude") with `anthropic-version:2023-06-01` header, bypassing Chat Completions quirks and `sanitizeMessagesForChatCompletions`. Result: native Anthropic-format responses for Claude Code, Cline, etc.
- **GitHub Copilot model catalog sync**: synced to OmniRoute fea1d54 (20 models, including the latest Claude 4.5/4.10 family).
- **opencode-go effort-tier aliases**: 9 new alias models (`glm-5.2-high/max`, `mimo-v2.5-high/max`, `deepseek-v4-pro-low/medium/high/max`) auto-rewriting model id + injecting `reasoning_effort` via a new EFFORT_TIERS table + `parseEffortLevel()` suffix parser (port of OmniRoute commit 1843b34).
- **Qwen3.8 Max Preview model**: added to Qwen Web (Subscription) catalog. Auto-enables thinking mode via `REQUIRED_THINKING_MODELS` set; SSE parser handles `thinking_summary` via `delta.extra.summary_thought.content[]`.

## Fixes
- **C1 Critical — Dashboard auth bypass**: `dashboardGuard.js` now uses method-based routing. Mutation routes (POST/PUT/PATCH/DELETE) **always** require a real JWT/CLI token even when `requireLogin=false`. Previously anyone with network access to the dashboard port could mutate state (create/delete providers, keys, settings) without authentication.
- **Cline/GLM 500 `stream_options` error**: `DefaultExecutor.transformRequest` now injects `stream_options: { include_usage: true }` on streaming requests so providers that require usage-on-stream don't 500. Signature expanded to `(model, body, stream, credentials)`.
- **HuggingChat provider dead (HTML 200 response)**: `zai-org/GLM-5.2` was retired by HF. Switched `DEFAULT_MODEL` to `omni` (HF's auto-router), always send `preprompt: ""`, switched `tlsFetch` → native `proxyAwareFetch` (no longer needs TLS impersonation), and added `isEncryptedCredentialBlob` guard.
- **xAI Quota Tracker empty card**: xAI removed both `/v1/billing?format=credits` and `/v1/user?include=subscription` (now 404) and migrated billing to a separate Management API on `management-api.x.ai` (requires a different key). Handler rewritten to return a clear `message` explaining where to find usage (`console.x.ai → Billing`) instead of leaving the card blank. No network calls — no more 404 spam.
- **ModelAccessModal crash (`Cannot read properties of null (reading 'length')`)**: `allowedModels` was `useState(null)` and only synced to an array inside `useEffect` (post-render), so the first render hit `.length` on null. Now uses lazy initializer `useState(() => [...])` so the first render already has a stable array.
- **TokenRouter quota `Cannot read properties of null (reading 'plan')`**: handler previously returned `null` when no management key was present; UI didn't guard. Fixed on both sides — handler returns an object with a `message` field (never null), and `ProviderLimits/index.js` uses `data ?? {}` for null-safe access.
- **Playground value/key TypeErrors (3 separate crashes)**: added `valueStr` coercion in ModelPicker, defensive coercion in `useModelCaps.getCaps`, and `model.value || model.name || model.id` extraction in the modal `onSelect` handler (modal passes the whole object, not a string).
- **Playground `[object Object]` model name after model swap**: extraction fix above also resolved the rendering bug.

## Improvements
- **SanitizeHtml utility** (`src/shared/utils/sanitizeHtml.js`): regex-based HTML sanitizer for markdown model output. Strips `<script>`/`<iframe>`/`<object>`/`<embed>`/`<form>`/`<style>`, neutralizes `on*` event handlers and `javascript:`/`data:` URLs. Defense-in-depth on top of `marked`'s AST (already constrained).
- **Playground MessageContent component**: renders assistant content as sanitized markdown, user/error as plain text. Code blocks get monospace styling + copy button.
- **AddApiKeyModal**: added TokenRouter management-key field UI (paired with chat API key).
- **Provider icon assets**: added forge, tokenrouter, qwen-cloud, qwen-cloud-token-plan, alibaba, alibaba-cn, hcnsec SVG icons + registered them in `providerIcon.js`'s `SVG_ICON_IDS`.
- **SanitizeHtml unit tests** (`tests/unit/sanitize-html.test.js`): regression coverage for the sanitizer.

# v0.7.2 (2026-07-18)

## Features
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rsalmn/extremerouter",
"version": "0.7.3",
"version": "0.7.4",
"description": "ExtremeRouter CLI - AI Gateway control plane",
"bin": {
"extremerouter": "./cli.js"
Expand Down
2 changes: 1 addition & 1 deletion open-sse/executors/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const headers = this.buildHeaders(credentials, stream);
const headers = this.buildHeaders(credentials, stream, model);

if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;

Expand Down
73 changes: 57 additions & 16 deletions open-sse/executors/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { getModelTargetFormat } from "../config/providerModels.js";
import crypto from "crypto";

export class GithubExecutor extends BaseExecutor {
Expand Down Expand Up @@ -38,12 +39,22 @@ export class GithubExecutor extends BaseExecutor {
}

buildUrl(model, stream, urlIndex = 0) {
// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the
// only Copilot endpoint that surfaces prompt-cache token counts for Claude
// and avoids a lossy round-trip of tool_use/tool_result/thinking content
// blocks through the OpenAI shape. Driven by the registry's per-model
// targetFormat (see registry/github.js), which chatCore also uses to
// translate the request to Claude shape before the executor ever sees it.
// Port of decolua/9router#2608.
if (getModelTargetFormat("gh", model) === "claude" && this.config.messagesUrl) {
return this.config.messagesUrl;
}
return this.config.baseUrl;
}

buildHeaders(credentials, stream = true) {
buildHeaders(credentials, stream = true, model = null) {
const token = credentials.copilotToken || credentials.accessToken;
return {
const headers = {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"copilot-integration-id": "vscode-chat",
Expand All @@ -57,6 +68,14 @@ export class GithubExecutor extends BaseExecutor {
"X-Initiator": "user",
"Accept": stream ? "text/event-stream" : "application/json"
};
// Claude models routed to the Anthropic-native /v1/messages shim require
// the anthropic-version header (harmless no-op on /chat/completions and
// /responses, but /v1/messages rejects the request without it).
// Port of decolua/9router#2608.
if (model && getModelTargetFormat("gh", model) === "claude") {
headers["anthropic-version"] = "2023-06-01";
}
return headers;
}

// Sanitize messages for GitHub Copilot /chat/completions endpoint.
Expand Down Expand Up @@ -131,14 +150,26 @@ export class GithubExecutor extends BaseExecutor {
}

transformRequest(model, body, stream, credentials) {
// Claude models arrive here already translated to Anthropic-native shape by
// chatCore (registry targetFormat: "claude") and are dispatched at /v1/messages
// (buildUrl above), which behaves like the real Anthropic API. The quirks
// below (max_tokens→max_completion_tokens rename, reasoning_effort="none"
// strip) are /chat/completions-only OpenAI-shape concerns — applying them
// to a Claude-shape body would either rename away a valid Anthropic field
// or strip thinking config the native endpoint honors. Skip them entirely
// for the native path. Port of decolua/9router#2608.
const isClaudeNative = getModelTargetFormat("gh", model) === "claude";

const transformed = { ...body };
if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) {
transformed.max_completion_tokens = transformed.max_tokens;
delete transformed.max_tokens;
}
// "none" means no thinking — strip it so models that don't support "none" don't 400
if (transformed.reasoning_effort === "none") {
delete transformed.reasoning_effort;
if (!isClaudeNative) {
if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) {
transformed.max_completion_tokens = transformed.max_tokens;
delete transformed.max_tokens;
}
// "none" means no thinking — strip it so models that don't support "none" don't 400
if (transformed.reasoning_effort === "none") {
delete transformed.reasoning_effort;
}
}
// Config-driven strip of params unsupported by this provider/model
stripUnsupportedParams("github", model, transformed);
Expand All @@ -165,19 +196,29 @@ export class GithubExecutor extends BaseExecutor {
return this.executeWithResponsesEndpoint(options);
}

// Sanitize messages before sending to /chat/completions
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
const sanitizedOptions = {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
};
// Claude models with targetFormat: "claude" are routed to the Anthropic-native
// /v1/messages shim (buildUrl) and have already been translated to Claude shape
// by chatCore. The /chat/completions sanitization below would corrupt native
// tool_use/tool_result/thinking content blocks (it serializes them as text),
// and the response_format-as-system-prompt workaround is unnecessary because
// /v1/messages honors JSON-mode natively. Skip sanitization for the native
// path. Port of decolua/9router#2608.
const isClaudeNative = getModelTargetFormat("gh", model) === "claude";

// Sanitize messages before sending to /chat/completions.
// This handles Claude models on GitHub Copilot which reject non-text/image_url
// content types — only applies to the legacy /chat/completions path now.
const sanitizedOptions = isClaudeNative
? options
: { ...options, body: this.sanitizeMessagesForChatCompletions(options.body) };

const result = await super.execute({ ...sanitizedOptions, proxyOptions: options.proxyOptions || null });

// Only escalate to /responses for models that endpoint can actually serve.
// Gemini/Claude would otherwise loop into a misleading "does not support
// Responses API" 400 instead of surfacing the real /chat/completions error (#1062).
if (result.response.status === HTTP_STATUS.BAD_REQUEST && this.supportsResponsesEndpoint(model)) {
// Claude native path already has its own endpoint — never escalate.
if (!isClaudeNative && result.response.status === HTTP_STATUS.BAD_REQUEST && this.supportsResponsesEndpoint(model)) {
const errorBody = await result.response.clone().text();

if (errorBody.includes("not accessible via the /chat/completions endpoint") || errorBody.includes("The requested model is not supported")) {
Expand Down
35 changes: 28 additions & 7 deletions open-sse/executors/huggingchat.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { PROVIDERS } from "../config/providers.js";
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js";
import { sseChunk } from "../utils/sse.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { tlsFetch } from "../utils/tlsClient.js";
// NOTE: tlsFetch is intentionally NOT imported here. HuggingFace sits behind
// CloudFront + AWS WAF which fingerprints TLS — the wreq-js impersonation
// signature triggers the WAF (causing HTML login redirects). Use native fetch.

// HuggingChatExecutor — HuggingChat (huggingface.co/chat) Web Provider.
//
Expand Down Expand Up @@ -33,7 +35,11 @@ const CONVERSATION_URL = `${HUGGINGFACE_BASE}/chat/conversation`;
const API_CONVERSATIONS_URL = `${HUGGINGFACE_BASE}/chat/api/v2/conversations`;

const DEFAULT_COOKIE_NAME = "hf-chat";
const DEFAULT_MODEL = "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT";
// Default to "omni" — HF's auto-router. Individual model ids get retired by
// HuggingFace without notice, which causes /chat/conversation to fall back to
// serving the HTML SPA shell instead of the JSON API response (200+HTML, no
// conversationId). The omni router is stable and always available.
const DEFAULT_MODEL = "omni";

const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
Expand Down Expand Up @@ -206,7 +212,12 @@ function extractInitialParentMessageId(value) {
}

async function fetchInitialParentMessageId(conversationId, headers, proxyOptions, signal) {
const res = await tlsFetch(
// Use proxyAwareFetch (native Node fetch) — NOT tlsFetch. HuggingFace sits
// behind CloudFront + AWS WAF which fingerprints the TLS layer; the wreq-js
// impersonation signature actually TRIGGERS the WAF (it doesn't match the
// real navigator/UA behaviour), causing HF to redirect to the HTML login SPA
// instead of returning JSON. Native fetch passes cleanly. Mirrors OmniRoute.
const res = await proxyAwareFetch(
`${API_CONVERSATIONS_URL}/${conversationId}`,
{ method: "GET", headers, signal },
proxyOptions
Expand Down Expand Up @@ -546,10 +557,18 @@ export class HuggingChatExecutor extends BaseExecutor {
// -- Step 1: Create conversation ----------------------------------------
let conversationId;
try {
const createBody = { model: resolvedModel };
if (systemPrompt) createBody.preprompt = systemPrompt;
// Build the create-conversation body. HuggingFace's SPA always sends
// `preprompt` (even as "") — omitting it has been observed to make the
// upstream fall back to serving the HTML SPA shell instead of the JSON
// API response. Always include the field.
const createBody = {
model: resolvedModel,
preprompt: systemPrompt || "",
};

const createRes = await tlsFetch(
// Use proxyAwareFetch (native Node fetch) — see fetchInitialParentMessageId
// note above. tlsFetch triggers CloudFront WAF → HTML login redirect.
const createRes = await proxyAwareFetch(
CONVERSATION_URL,
{
method: "POST",
Expand Down Expand Up @@ -655,7 +674,9 @@ export class HuggingChatExecutor extends BaseExecutor {

let upstreamResponse;
try {
upstreamResponse = await tlsFetch(
// Use proxyAwareFetch (native Node fetch) — see note above. tlsFetch
// triggers CloudFront WAF → HTML login redirect.
upstreamResponse = await proxyAwareFetch(
messageUrl,
{
method: "POST",
Expand Down
71 changes: 66 additions & 5 deletions open-sse/executors/opencode-go.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,57 @@ const MESSAGES_FORMAT_MODELS = new Set([

const BASE = "https://opencode.ai/zen/go/v1";

// Effort-tier aliases — models on opencode-go that support per-effort suffixes.
// Each entry maps the canonical base id to the set of effort tiers the upstream
// supports. parseEffortLevel() parses the suffix (e.g. "glm-5.2-high" →
// baseModel "glm-5.2", effort "high"), transformRequest rewrites body.model to
// the canonical id and injects reasoning_effort if not already set by client.
//
// Tier support varies per upstream:
// - deepseek-v4-pro: all four tiers (low/medium/high/max)
// - glm-5.2: high/max only (Z.AI maps these through the reasoning
// plane; low/medium are not supported on OpenAI transport)
// - mimo-v2.5: high/max only (Xiaomi MiMo does not document low/medium)
//
// Port of OmniRoute commit 1843b34 (PR #6987, issue #6922).
const EFFORT_LEVELS = ["low", "medium", "high", "max"];
const EFFORT_TIERS = {
"deepseek-v4-pro": EFFORT_LEVELS,
"glm-5.2": ["high", "max"],
"mimo-v2.5": ["high", "max"],
};

/**
* Parse a model string with an effort-level suffix.
* e.g. "deepseek-v4-pro-low" → { baseModel: "deepseek-v4-pro", effort: "low" }
* "glm-5.2-high" → { baseModel: "glm-5.2", effort: "high" }
* Returns null if the model doesn't match any known effort-tier pattern.
*/
export function parseEffortLevel(model) {
const m = String(model || "");
for (const [baseModel, levels] of Object.entries(EFFORT_TIERS)) {
for (const level of levels) {
if (m === `${baseModel}-${level}`) {
return { baseModel, effort: level };
}
}
}
return null;
}

export class OpenCodeGoExecutor extends BaseExecutor {
constructor() {
super("opencode-go", PROVIDERS["opencode-go"]);
}

// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache the
// CANONICAL model here (strip effort-tier suffix if present) so buildHeaders
// checks the right id against MESSAGES_FORMAT_MODELS.
buildUrl(model) {
this._lastModel = model;
return MESSAGES_FORMAT_MODELS.has(model)
const parsed = parseEffortLevel(model);
const canonical = parsed ? parsed.baseModel : model;
this._lastModel = canonical;
return MESSAGES_FORMAT_MODELS.has(canonical)
? `${BASE}/messages`
: `${BASE}/chat/completions`;
}
Expand All @@ -43,7 +85,26 @@ export class OpenCodeGoExecutor extends BaseExecutor {
return headers;
}

transformRequest(model, body) {
return injectReasoningContent({ provider: this.provider, model, body });
transformRequest(model, body, stream, credentials) {
const transformed = { ...(body && typeof body === "object" ? body : {}) };

// Effort-tier alias: rewrite body.model to canonical id and inject
// reasoning_effort (only if the client hasn't set one explicitly).
const parsed = parseEffortLevel(model);
if (parsed) {
transformed.model = parsed.baseModel;
if (transformed.reasoning_effort === undefined) {
transformed.reasoning_effort = parsed.effort;
}
// Pass the canonical model to injectReasoningContent so capability
// lookups (which key on base ids, not aliases) resolve correctly.
return injectReasoningContent({
provider: this.provider,
model: parsed.baseModel,
body: transformed,
});
}

return injectReasoningContent({ provider: this.provider, model, body: transformed });
}
}
Loading