mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(glm): add dedicated coding transport (#2087)
Integrated into release/v3.8.0
This commit is contained in:
18
.env.example
18
.env.example
@@ -460,15 +460,15 @@ QODER_OAUTH_CLIENT_SECRET=4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT=claude-cli/2.1.121 (external, cli)
|
||||
CODEX_USER_AGENT=codex-cli/0.125.0 (Windows 10.0.26100; x64)
|
||||
GITHUB_USER_AGENT=GitHubCopilotChat/0.45.1
|
||||
ANTIGRAVITY_USER_AGENT=antigravity/1.107.0 darwin/arm64
|
||||
KIRO_USER_AGENT=AWS-SDK-JS/3.0.0 kiro-ide/1.0.0
|
||||
QODER_USER_AGENT=Qoder-Cli
|
||||
QWEN_USER_AGENT=QwenCode/0.15.3 (linux; x64)
|
||||
CURSOR_USER_AGENT=connect-es/1.6.1
|
||||
GEMINI_CLI_USER_AGENT=google-api-nodejs-client/10.3.0
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.137 (external, cli)"
|
||||
CODEX_USER_AGENT="codex-cli/0.130.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/1.23.2 darwin/arm64"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
QODER_USER_AGENT="Qoder-Cli"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.9 (linux; x64)"
|
||||
CURSOR_USER_AGENT="Cursor/3.3"
|
||||
GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -26,10 +26,7 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [
|
||||
...ANTHROPIC_BETA_BASE.slice(3),
|
||||
].join(",");
|
||||
|
||||
// Static-config fallbacks for providerRegistry.ts. Runtime cloak in base.ts
|
||||
// emits the same values via CLAUDE_CODE_VERSION in claudeIdentity.ts —
|
||||
// keep both in sync.
|
||||
export const CLAUDE_CLI_VERSION = "2.1.131";
|
||||
export const CLAUDE_CLI_VERSION = "2.1.137";
|
||||
export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`;
|
||||
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.81.0";
|
||||
export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const DEFAULT_CODEX_CLIENT_VERSION = "0.125.0";
|
||||
const DEFAULT_CODEX_CLIENT_VERSION = "0.130.0";
|
||||
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
|
||||
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
|
||||
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
|
||||
|
||||
@@ -3,22 +3,101 @@ import { ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts";
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type GlmApiRegion = "international" | "china";
|
||||
export type GlmTransport = "openai" | "anthropic";
|
||||
|
||||
export const GLM_SHARED_HEADERS = Object.freeze({
|
||||
"Anthropic-Version": ANTHROPIC_VERSION_HEADER,
|
||||
export const GLM_DEFAULT_BASE_URLS = Object.freeze({
|
||||
international: "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
china: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
||||
});
|
||||
|
||||
export const GLM_ANTHROPIC_DEFAULT_BASE_URLS = Object.freeze({
|
||||
international: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
china: "https://open.bigmodel.cn/api/anthropic/v1/messages",
|
||||
});
|
||||
|
||||
export const GLM_SHARED_MODELS = Object.freeze([
|
||||
{ id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)", contextLength: 128000 },
|
||||
{ id: "glm-4.6", name: "GLM 4.6" },
|
||||
{ id: "glm-4.5v", name: "GLM 4.5V (Vision)", contextLength: 16000 },
|
||||
{ id: "glm-4.5", name: "GLM 4.5", contextLength: 128000 },
|
||||
{ id: "glm-4.5-air", name: "GLM 4.5 Air", contextLength: 128000 },
|
||||
{
|
||||
id: "glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
contextLength: 204800,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "GLM 5",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-5-turbo",
|
||||
name: "GLM 5 Turbo",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7-flash",
|
||||
name: "GLM 4.7 Flash",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 131072,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.6v",
|
||||
name: "GLM 4.6V (Vision)",
|
||||
contextLength: 128000,
|
||||
maxOutputTokens: 32768,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.6",
|
||||
name: "GLM 4.6",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 32768,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.5v",
|
||||
name: "GLM 4.5V (Vision)",
|
||||
contextLength: 16000,
|
||||
maxOutputTokens: 32768,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.5",
|
||||
name: "GLM 4.5",
|
||||
contextLength: 128000,
|
||||
maxOutputTokens: 32768,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-4.5-air",
|
||||
name: "GLM 4.5 Air",
|
||||
contextLength: 128000,
|
||||
maxOutputTokens: 32768,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
export const GLM_MODELS_URLS = Object.freeze({
|
||||
@@ -33,25 +112,258 @@ export const GLM_QUOTA_URLS = Object.freeze({
|
||||
|
||||
export const GLMT_TIMEOUT_MS = 900_000;
|
||||
|
||||
export const GLM_TIMEOUT_MS = 900_000;
|
||||
|
||||
export const GLM_REQUEST_DEFAULTS = Object.freeze({
|
||||
maxTokens: 16_384,
|
||||
});
|
||||
|
||||
export const GLMT_REQUEST_DEFAULTS = Object.freeze({
|
||||
maxTokens: 65_536,
|
||||
temperature: 0.2,
|
||||
thinkingBudgetTokens: 24_576,
|
||||
thinkingType: "adaptive" as const,
|
||||
});
|
||||
|
||||
export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000;
|
||||
export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.137 (external, sdk-cli)";
|
||||
export const GLM_ANTHROPIC_BETA = [
|
||||
"claude-code-20250219",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"context-management-2025-06-27",
|
||||
"prompt-caching-scope-2026-01-05",
|
||||
"advisor-tool-2026-03-01",
|
||||
"effort-2025-11-24",
|
||||
].join(",");
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function splitUrlQueryAndHash(url: string): { base: string; suffix: string } {
|
||||
const match = url.match(/^([^?#]*)(.*)$/);
|
||||
return { base: match?.[1] ?? url, suffix: match?.[2] ?? "" };
|
||||
}
|
||||
|
||||
export function getGlmApiRegion(providerSpecificData: unknown): GlmApiRegion {
|
||||
const data = asRecord(providerSpecificData);
|
||||
return data.apiRegion === "china" ? "china" : "international";
|
||||
}
|
||||
|
||||
export function getGlmModelsUrl(providerSpecificData: unknown): string {
|
||||
export function buildGlmModelsUrl(
|
||||
providerSpecificData: unknown,
|
||||
transport: GlmTransport = "openai",
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const customModelsUrl = asString(data.modelsUrl);
|
||||
if (customModelsUrl) return customModelsUrl;
|
||||
|
||||
if (transport === "anthropic") {
|
||||
return joinGlmBaseAndPath(
|
||||
getGlmAnthropicBaseUrl(providerSpecificData, fallbackBaseUrl),
|
||||
"/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
const configuredBaseUrl = asString(data.baseUrl);
|
||||
if (configuredBaseUrl) {
|
||||
if (isAnthropicGlmBaseUrl(configuredBaseUrl)) {
|
||||
return GLM_MODELS_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
}
|
||||
return joinGlmBaseAndPath(configuredBaseUrl, "/models");
|
||||
}
|
||||
return GLM_MODELS_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
}
|
||||
|
||||
export function getGlmQuotaUrl(providerSpecificData: unknown): string {
|
||||
return GLM_QUOTA_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
}
|
||||
|
||||
function stripKnownGlmEndpointSuffix(baseUrl: string): { base: string; suffix: string } {
|
||||
const parts = splitUrlQueryAndHash(baseUrl);
|
||||
const base = parts.base
|
||||
.replace(/\/+$/g, "")
|
||||
.replace(/\/(?:v\d+\/)?messages\/count_tokens$/i, "")
|
||||
.replace(/\/(?:v\d+\/)?messages$/i, "")
|
||||
.replace(/\/chat\/completions$/i, "")
|
||||
.replace(/\/models$/i, "");
|
||||
return { base, suffix: parts.suffix };
|
||||
}
|
||||
|
||||
function joinGlmBaseAndPath(baseUrl: string, path: string): string {
|
||||
const { base, suffix } = stripKnownGlmEndpointSuffix(baseUrl);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const versionMatch = base.match(/\/v\d+$/i);
|
||||
if (
|
||||
versionMatch &&
|
||||
normalizedPath.toLowerCase().startsWith(`${versionMatch[0].toLowerCase()}/`)
|
||||
) {
|
||||
return `${base}${normalizedPath.slice(versionMatch[0].length)}${suffix}`;
|
||||
}
|
||||
return `${base}${normalizedPath}${suffix}`;
|
||||
}
|
||||
|
||||
function stripQueryAndTrailingSlash(baseUrl: string): string {
|
||||
return splitUrlQueryAndHash(baseUrl).base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function addBetaQuery(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set("beta", "true");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function isAnthropicGlmBaseUrl(baseUrl: string): boolean {
|
||||
return /\/api\/anthropic(?:\/|$)/i.test(stripQueryAndTrailingSlash(baseUrl));
|
||||
}
|
||||
|
||||
export function isCodingGlmBaseUrl(baseUrl: string): boolean {
|
||||
return /\/api\/coding\/paas\/v\d+(?:\/|$)/i.test(stripQueryAndTrailingSlash(baseUrl));
|
||||
}
|
||||
|
||||
export function getGlmBaseUrl(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const configuredBaseUrl = asString(data.baseUrl);
|
||||
if (configuredBaseUrl) return configuredBaseUrl;
|
||||
const regionalBaseUrl =
|
||||
typeof fallbackBaseUrl === "string" &&
|
||||
fallbackBaseUrl.trim() &&
|
||||
isCodingGlmBaseUrl(fallbackBaseUrl)
|
||||
? fallbackBaseUrl.trim()
|
||||
: GLM_DEFAULT_BASE_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
if (regionalBaseUrl) return regionalBaseUrl;
|
||||
return typeof fallbackBaseUrl === "string" && fallbackBaseUrl.trim()
|
||||
? fallbackBaseUrl.trim()
|
||||
: GLM_DEFAULT_BASE_URLS.international;
|
||||
}
|
||||
|
||||
export function getGlmAnthropicBaseUrl(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const anthropicBaseUrl = asString(data.anthropicBaseUrl);
|
||||
if (anthropicBaseUrl) return anthropicBaseUrl;
|
||||
|
||||
const configuredBaseUrl = asString(data.baseUrl);
|
||||
if (configuredBaseUrl) {
|
||||
if (isCodingGlmBaseUrl(configuredBaseUrl)) {
|
||||
return GLM_ANTHROPIC_DEFAULT_BASE_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
}
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
if (
|
||||
typeof fallbackBaseUrl === "string" &&
|
||||
fallbackBaseUrl.trim() &&
|
||||
isCodingGlmBaseUrl(fallbackBaseUrl)
|
||||
) {
|
||||
return GLM_ANTHROPIC_DEFAULT_BASE_URLS[
|
||||
fallbackBaseUrl.includes("open.bigmodel.cn") ? "china" : getGlmApiRegion(providerSpecificData)
|
||||
];
|
||||
}
|
||||
if (
|
||||
typeof fallbackBaseUrl === "string" &&
|
||||
fallbackBaseUrl.trim() &&
|
||||
!isCodingGlmBaseUrl(fallbackBaseUrl)
|
||||
) {
|
||||
return fallbackBaseUrl.trim();
|
||||
}
|
||||
return GLM_ANTHROPIC_DEFAULT_BASE_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
}
|
||||
|
||||
export function getGlmPrimaryTransport(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): GlmTransport {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const configuredTransport = asString(data.primaryTransport);
|
||||
if (configuredTransport === "anthropic") return "anthropic";
|
||||
if (configuredTransport === "openai") return "openai";
|
||||
return isAnthropicGlmBaseUrl(getGlmBaseUrl(providerSpecificData, fallbackBaseUrl))
|
||||
? "anthropic"
|
||||
: "openai";
|
||||
}
|
||||
|
||||
export function getGlmTransport(providerSpecificData: unknown, fallbackBaseUrl?: string | null) {
|
||||
return getGlmPrimaryTransport(providerSpecificData, fallbackBaseUrl);
|
||||
}
|
||||
|
||||
export function buildGlmChatUrl(
|
||||
providerSpecificData: unknown,
|
||||
transport: GlmTransport = "openai",
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
if (transport === "anthropic") {
|
||||
return buildGlmAnthropicMessagesUrl(providerSpecificData, fallbackBaseUrl);
|
||||
}
|
||||
return buildGlmOpenAIChatUrl(providerSpecificData, fallbackBaseUrl);
|
||||
}
|
||||
|
||||
export function buildGlmOpenAIChatUrl(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
const configuredBaseUrl = getGlmBaseUrl(providerSpecificData, fallbackBaseUrl);
|
||||
const baseUrl = isAnthropicGlmBaseUrl(configuredBaseUrl)
|
||||
? GLM_DEFAULT_BASE_URLS[getGlmApiRegion(providerSpecificData)]
|
||||
: configuredBaseUrl;
|
||||
return joinGlmBaseAndPath(baseUrl, "/chat/completions");
|
||||
}
|
||||
|
||||
export function buildGlmAnthropicMessagesUrl(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
return addBetaQuery(
|
||||
joinGlmBaseAndPath(
|
||||
getGlmAnthropicBaseUrl(providerSpecificData, fallbackBaseUrl),
|
||||
"/v1/messages"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGlmCountTokensUrl(
|
||||
providerSpecificData: unknown,
|
||||
fallbackBaseUrl?: string | null
|
||||
): string {
|
||||
return addBetaQuery(
|
||||
joinGlmBaseAndPath(
|
||||
getGlmAnthropicBaseUrl(providerSpecificData, fallbackBaseUrl),
|
||||
"/v1/messages/count_tokens"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGlmCodingHeaders(apiKey: string, stream = true): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGlmBaseHeaders(apiKey: string, stream = true): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION_HEADER,
|
||||
"anthropic-beta": GLM_ANTHROPIC_BETA,
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"User-Agent": GLM_CLAUDE_CODE_USER_AGENT,
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"accept-language": "*",
|
||||
"accept-encoding": "gzip, deflate, br, zstd",
|
||||
connection: "keep-alive",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-panel";
|
||||
export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user";
|
||||
export const GITHUB_COPILOT_USER_AGENT_LIBRARY = "electron-fetch";
|
||||
|
||||
export const QWEN_CLI_VERSION = "0.15.4";
|
||||
export const QWEN_CLI_VERSION = "0.15.9";
|
||||
export const QWEN_STAINLESS_LANG = "js";
|
||||
export const QWEN_STAINLESS_PACKAGE_VERSION = "5.11.0";
|
||||
export const QWEN_STAINLESS_RETRY_COUNT = "1";
|
||||
@@ -26,7 +26,7 @@ export const KIRO_AMZ_USER_AGENT = "aws-sdk-js/3.0.0 kiro-ide/1.0.0";
|
||||
export const KIRO_STREAMING_TARGET =
|
||||
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
|
||||
|
||||
export const CURSOR_REGISTRY_VERSION = "3.2.14";
|
||||
export const CURSOR_REGISTRY_VERSION = "3.3";
|
||||
|
||||
export function getGitHubCopilotChatHeaders(
|
||||
accept = "application/json",
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
} from "./anthropicHeaders.ts";
|
||||
import { getCodexDefaultHeaders } from "./codexClient.ts";
|
||||
import {
|
||||
GLM_REQUEST_DEFAULTS,
|
||||
GLMT_REQUEST_DEFAULTS,
|
||||
GLM_TIMEOUT_MS,
|
||||
GLMT_TIMEOUT_MS,
|
||||
GLM_SHARED_HEADERS,
|
||||
GLM_SHARED_MODELS,
|
||||
} from "./glmProvider.ts";
|
||||
import { MARITALK_DEFAULT_BASE_URL } from "./maritalk.ts";
|
||||
@@ -46,6 +47,7 @@ export interface RegistryModel {
|
||||
supportsReasoning?: boolean;
|
||||
supportsVision?: boolean;
|
||||
supportsXHighEffort?: boolean;
|
||||
maxOutputTokens?: number;
|
||||
targetFormat?: string;
|
||||
strip?: readonly string[];
|
||||
unsupportedParams?: readonly string[];
|
||||
@@ -880,14 +882,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
glm: {
|
||||
id: "glm",
|
||||
alias: "glm",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
format: "openai",
|
||||
executor: "glm",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
defaultContextLength: 200000,
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
headers: GLM_SHARED_HEADERS,
|
||||
authHeader: "bearer",
|
||||
requestDefaults: GLM_REQUEST_DEFAULTS,
|
||||
timeoutMs: GLM_TIMEOUT_MS,
|
||||
models: [...GLM_SHARED_MODELS],
|
||||
},
|
||||
|
||||
@@ -895,26 +897,26 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
id: "glm-cn",
|
||||
alias: "glm-cn",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
executor: "glm",
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
defaultContextLength: 200000,
|
||||
models: [{ id: "glm-5.1", name: "GLM-5.1" }],
|
||||
requestDefaults: GLM_REQUEST_DEFAULTS,
|
||||
timeoutMs: GLM_TIMEOUT_MS,
|
||||
models: [...GLM_SHARED_MODELS],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
glmt: {
|
||||
id: "glmt",
|
||||
alias: "glmt",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
format: "openai",
|
||||
executor: "glm",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
defaultContextLength: 200000,
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
headers: GLM_SHARED_HEADERS,
|
||||
authHeader: "bearer",
|
||||
requestDefaults: GLMT_REQUEST_DEFAULTS,
|
||||
timeoutMs: GLMT_TIMEOUT_MS,
|
||||
models: [...GLM_SHARED_MODELS],
|
||||
|
||||
@@ -453,7 +453,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return { ...c, role, parts };
|
||||
}) || [];
|
||||
|
||||
const contents = [];
|
||||
const contents: any[] = [];
|
||||
for (const c of normalizedContents) {
|
||||
if (!Array.isArray(c.parts) || c.parts.length === 0) continue;
|
||||
if (contents.length > 0 && contents[contents.length - 1].role === c.role) {
|
||||
@@ -535,9 +535,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: credentials.refreshToken,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
refresh_token: credentials.refreshToken || "",
|
||||
client_id: this.config.clientId || "",
|
||||
client_secret: this.config.clientSecret || "",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -802,7 +802,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: finalHeaders,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
@@ -814,7 +814,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
@@ -829,7 +829,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// Parse retry time for 429/503 responses
|
||||
let retryMs = null;
|
||||
let retryMs: number | null = null;
|
||||
|
||||
if (
|
||||
response.status === HTTP_STATUS.RATE_LIMITED ||
|
||||
@@ -884,7 +884,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const creditsResp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: finalCreditsHeaders,
|
||||
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream),
|
||||
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream) as any,
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -205,6 +205,10 @@ export class BaseExecutor {
|
||||
return Math.max(1, Math.floor(configured));
|
||||
}
|
||||
|
||||
getCountTokensTimeoutMs() {
|
||||
return this.getTimeoutMs();
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
model: string,
|
||||
stream: boolean,
|
||||
@@ -228,7 +232,7 @@ export class BaseExecutor {
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
const baseUrls = this.getBaseUrls();
|
||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl || "";
|
||||
}
|
||||
|
||||
buildHeaders(
|
||||
@@ -333,7 +337,10 @@ export class BaseExecutor {
|
||||
static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS;
|
||||
|
||||
// Override in subclass for provider-specific refresh
|
||||
async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) {
|
||||
async refreshCredentials(
|
||||
credentials: ProviderCredentials,
|
||||
log: ExecutorLog | null
|
||||
): Promise<Partial<ProviderCredentials> | null> {
|
||||
void credentials;
|
||||
void log;
|
||||
return null;
|
||||
@@ -379,12 +386,12 @@ export class BaseExecutor {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeSignal = signal || null;
|
||||
let controller: AbortController | null = null;
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
const timeoutMs = this.getCountTokensTimeoutMs();
|
||||
|
||||
if (!activeSignal) {
|
||||
if (timeoutMs > 0) {
|
||||
controller = new AbortController();
|
||||
timeoutId = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
activeSignal = controller.signal;
|
||||
activeSignal = signal ? mergeAbortSignals(signal, controller.signal) : controller.signal;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1088,7 +1088,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
|
||||
void model;
|
||||
void stream;
|
||||
void urlIndex;
|
||||
|
||||
400
open-sse/executors/glm.ts
Normal file
400
open-sse/executors/glm.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import {
|
||||
applyConfiguredUserAgent,
|
||||
mergeAbortSignals,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type CountTokensInput,
|
||||
type ExecuteInput,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import {
|
||||
buildGlmBaseHeaders,
|
||||
buildGlmChatUrl,
|
||||
buildGlmCodingHeaders,
|
||||
buildGlmCountTokensUrl,
|
||||
GLM_COUNT_TOKENS_TIMEOUT_MS,
|
||||
type GlmTransport,
|
||||
getGlmTransport,
|
||||
} from "../config/glmProvider.ts";
|
||||
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
import { CLAUDE_CLI_STAINLESS_PACKAGE_VERSION } from "../config/anthropicHeaders.ts";
|
||||
import {
|
||||
getRuntimeVersion,
|
||||
normalizeStainlessArch,
|
||||
normalizeStainlessPlatform,
|
||||
} from "../config/providerHeaderProfiles.ts";
|
||||
import { translateNonStreamingResponse } from "../handlers/responseTranslator.ts";
|
||||
import { translateRequest } from "../translator/index.ts";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { createSSETransformStreamWithLogger } from "../utils/stream.ts";
|
||||
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
|
||||
import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type GlmExecuteResult = Awaited<ReturnType<DefaultExecutor["execute"]>> & {
|
||||
targetFormat?: string;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
|
||||
}
|
||||
|
||||
function getEffectiveKey(credentials: ProviderCredentials): string {
|
||||
const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
if (credentials.apiKey && credentials.connectionId && extraKeys.length > 0) {
|
||||
return getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys);
|
||||
}
|
||||
return credentials.apiKey || credentials.accessToken || "";
|
||||
}
|
||||
|
||||
function applyGlmRequestDefaults(body: unknown, defaults?: JsonRecord | null): unknown {
|
||||
const record = asRecord(body);
|
||||
if (!record || !defaults) return body;
|
||||
|
||||
const next = { ...(applyProviderRequestDefaults(record, defaults) as JsonRecord) };
|
||||
const thinkingType = typeof defaults.thinkingType === "string" ? defaults.thinkingType : null;
|
||||
|
||||
if (thinkingType && next.thinking === undefined) {
|
||||
next.thinking = { type: thinkingType };
|
||||
} else if (thinkingType && asRecord(next.thinking)?.type === "enabled") {
|
||||
next.thinking = { ...asRecord(next.thinking), type: thinkingType };
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasTools(body: unknown): boolean {
|
||||
const record = asRecord(body);
|
||||
return Array.isArray(record?.tools) && record.tools.length > 0;
|
||||
}
|
||||
|
||||
function isRetryableGlmFallbackStatus(status: number): boolean {
|
||||
return status === 404 || status === 408 || status === 409 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function isRetryableGlmFallbackError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if (err.name === "AbortError") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function cloneHeaders(headers: Headers): Headers {
|
||||
const next = new Headers();
|
||||
headers.forEach((value, key) => next.set(key, value));
|
||||
return next;
|
||||
}
|
||||
|
||||
function isJsonResponse(response: Response): boolean {
|
||||
return (response.headers.get("content-type") || "").toLowerCase().includes("application/json");
|
||||
}
|
||||
|
||||
async function translateJsonResponse(response: Response): Promise<Response> {
|
||||
const parsed = await response.json().catch(() => null);
|
||||
const translated = translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI);
|
||||
const headers = cloneHeaders(response.headers);
|
||||
headers.set("content-type", "application/json");
|
||||
headers.delete("content-length");
|
||||
return new Response(JSON.stringify(translated), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function translateAnthropicJsonResponse(response: Response): Promise<Response> {
|
||||
const parsed = await response.json().catch(() => null);
|
||||
const translated = response.ok
|
||||
? translateNonStreamingResponse(parsed, FORMATS.CLAUDE, FORMATS.OPENAI)
|
||||
: translateAnthropicJsonError(parsed);
|
||||
const headers = cloneHeaders(response.headers);
|
||||
headers.set("content-type", "application/json");
|
||||
headers.delete("content-length");
|
||||
return new Response(JSON.stringify(translated), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function translateAnthropicJsonError(parsed: unknown): JsonRecord {
|
||||
const root = asRecord(parsed) || {};
|
||||
const error = asRecord(root.error) || root;
|
||||
const message =
|
||||
typeof error.message === "string" && error.message.trim()
|
||||
? error.message
|
||||
: typeof root.message === "string" && root.message.trim()
|
||||
? root.message
|
||||
: "GLM Anthropic transport error";
|
||||
const type =
|
||||
typeof error.type === "string" && error.type.trim()
|
||||
? error.type
|
||||
: typeof root.type === "string" && root.type.trim()
|
||||
? root.type
|
||||
: "upstream_error";
|
||||
|
||||
return {
|
||||
error: {
|
||||
message,
|
||||
type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function translateSseResponse(response: Response, provider: string, model: string): Response {
|
||||
if (!response.body) return response;
|
||||
const transform = createSSETransformStreamWithLogger(
|
||||
FORMATS.CLAUDE,
|
||||
FORMATS.OPENAI,
|
||||
provider,
|
||||
null,
|
||||
null,
|
||||
model
|
||||
);
|
||||
const headers = cloneHeaders(response.headers);
|
||||
headers.set("content-type", "text/event-stream");
|
||||
headers.delete("content-length");
|
||||
return new Response(response.body.pipeThrough(transform), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export class GlmExecutor extends DefaultExecutor {
|
||||
constructor(provider = "glm") {
|
||||
super(provider);
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
_model: string,
|
||||
_stream: boolean,
|
||||
_urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
) {
|
||||
const primaryTransport = getGlmTransport(credentials?.providerSpecificData);
|
||||
const transport =
|
||||
_urlIndex === 1 ? (primaryTransport === "openai" ? "anthropic" : "openai") : primaryTransport;
|
||||
return buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
||||
}
|
||||
|
||||
buildCountTokensUrl(_model: string, credentials: ProviderCredentials | null = null) {
|
||||
return buildGlmCountTokensUrl(credentials?.providerSpecificData, this.config.baseUrl);
|
||||
}
|
||||
|
||||
getCountTokensTimeoutMs() {
|
||||
return GLM_COUNT_TOKENS_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
buildHeaders(
|
||||
credentials: ProviderCredentials,
|
||||
stream = true,
|
||||
_clientHeaders?: Record<string, string> | null,
|
||||
_model?: string,
|
||||
transport: GlmTransport = getGlmTransport(credentials.providerSpecificData)
|
||||
): Record<string, string> {
|
||||
if (transport === "openai") {
|
||||
return buildGlmCodingHeaders(getEffectiveKey(credentials), stream);
|
||||
}
|
||||
|
||||
return {
|
||||
...buildGlmBaseHeaders(getEffectiveKey(credentials), stream),
|
||||
"X-Stainless-Arch": normalizeStainlessArch(),
|
||||
"X-Stainless-OS": normalizeStainlessPlatform(),
|
||||
"X-Stainless-Runtime-Version": getRuntimeVersion(),
|
||||
"X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
|
||||
"X-Claude-Code-Session-Id": randomUUID(),
|
||||
"x-client-request-id": randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
stream: boolean,
|
||||
credentials: ProviderCredentials
|
||||
) {
|
||||
const cleanedBody = super.transformRequest(model, body, stream, credentials);
|
||||
return applyGlmRequestDefaults(cleanedBody, this.config.requestDefaults as JsonRecord | null);
|
||||
}
|
||||
|
||||
transformForTransport(
|
||||
model: string,
|
||||
body: unknown,
|
||||
stream: boolean,
|
||||
credentials: ProviderCredentials,
|
||||
transport: GlmTransport
|
||||
) {
|
||||
const transformed = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
if (transport === "openai") {
|
||||
const record = asRecord(transformed);
|
||||
if (record && stream && hasTools(record) && record.tool_stream === undefined) {
|
||||
return { ...record, tool_stream: true };
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
return translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
model,
|
||||
{ ...(transformed as JsonRecord), _disableToolPrefix: true },
|
||||
stream,
|
||||
credentials,
|
||||
this.provider,
|
||||
null,
|
||||
{ preserveCacheControl: false }
|
||||
);
|
||||
}
|
||||
|
||||
private async executeTransport(
|
||||
input: ExecuteInput,
|
||||
transport: GlmTransport
|
||||
): Promise<GlmExecuteResult> {
|
||||
const credentials = input.credentials;
|
||||
const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
||||
const headers = this.buildHeaders(
|
||||
credentials,
|
||||
input.stream,
|
||||
input.clientHeaders,
|
||||
input.model,
|
||||
transport
|
||||
);
|
||||
applyConfiguredUserAgent(headers, credentials.providerSpecificData);
|
||||
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
||||
|
||||
const transformedBody = this.transformForTransport(
|
||||
input.model,
|
||||
input.body,
|
||||
input.stream,
|
||||
credentials,
|
||||
transport
|
||||
);
|
||||
|
||||
const fetchStartTimeoutMs = this.getTimeoutMs();
|
||||
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
if (timeoutController) {
|
||||
timeoutId = setTimeout(() => {
|
||||
const timeoutError = new Error(`Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}`);
|
||||
timeoutError.name = "TimeoutError";
|
||||
timeoutController.abort(timeoutError);
|
||||
}, fetchStartTimeoutMs);
|
||||
}
|
||||
|
||||
const timeoutSignal = timeoutController?.signal ?? null;
|
||||
const combinedSignal =
|
||||
input.signal && timeoutSignal
|
||||
? mergeAbortSignals(input.signal, timeoutSignal)
|
||||
: input.signal || timeoutSignal;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: combinedSignal || undefined,
|
||||
});
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (input.stream && response.ok) {
|
||||
const readiness = await ensureStreamReadiness(response, {
|
||||
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
||||
provider: this.provider,
|
||||
model: input.model,
|
||||
log: input.log,
|
||||
});
|
||||
response = readiness.response;
|
||||
}
|
||||
|
||||
const result = { response, url, headers, transformedBody };
|
||||
|
||||
if (transport === "anthropic") {
|
||||
const translatedResponse =
|
||||
input.stream && result.response.ok
|
||||
? translateSseResponse(result.response, this.provider, input.model)
|
||||
: isJsonResponse(result.response)
|
||||
? await translateAnthropicJsonResponse(result.response)
|
||||
: result.response;
|
||||
return {
|
||||
...result,
|
||||
response: translatedResponse,
|
||||
url,
|
||||
headers,
|
||||
transformedBody,
|
||||
targetFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
url,
|
||||
headers,
|
||||
transformedBody,
|
||||
targetFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<GlmExecuteResult> {
|
||||
const primaryTransport = getGlmTransport(
|
||||
input.credentials.providerSpecificData,
|
||||
this.config.baseUrl
|
||||
);
|
||||
const fallbackTransport: GlmTransport = primaryTransport === "openai" ? "anthropic" : "openai";
|
||||
|
||||
let primaryResult: GlmExecuteResult | null = null;
|
||||
try {
|
||||
primaryResult = await this.executeTransport(input, primaryTransport);
|
||||
if (!isRetryableGlmFallbackStatus(primaryResult.response.status)) {
|
||||
return primaryResult;
|
||||
}
|
||||
input.log?.debug?.(
|
||||
"GLM_FALLBACK",
|
||||
`${primaryTransport} returned ${primaryResult.response.status}; trying ${fallbackTransport}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isRetryableGlmFallbackError(error)) throw error;
|
||||
input.log?.debug?.(
|
||||
"GLM_FALLBACK",
|
||||
`${primaryTransport} error (${error instanceof Error ? error.message : String(error)}); trying ${fallbackTransport}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const fallbackResult = await this.executeTransport(input, fallbackTransport);
|
||||
if (fallbackResult.response.ok || !primaryResult) {
|
||||
return fallbackResult;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!primaryResult) throw error;
|
||||
input.log?.debug?.(
|
||||
"GLM_FALLBACK",
|
||||
`${fallbackTransport} fallback failed (${error instanceof Error ? error.message : String(error)}); returning primary response`
|
||||
);
|
||||
}
|
||||
|
||||
return primaryResult;
|
||||
}
|
||||
|
||||
async countTokens(input: CountTokensInput) {
|
||||
return super.countTokens({
|
||||
...input,
|
||||
credentials: {
|
||||
...input.credentials,
|
||||
providerSpecificData: {
|
||||
...(input.credentials.providerSpecificData || {}),
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default GlmExecutor;
|
||||
@@ -6,6 +6,7 @@ import { KiroExecutor } from "./kiro.ts";
|
||||
import { CodexExecutor } from "./codex.ts";
|
||||
import { CursorExecutor } from "./cursor.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import { GlmExecutor } from "./glm.ts";
|
||||
import { PollinationsExecutor } from "./pollinations.ts";
|
||||
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
import { OpencodeExecutor } from "./opencode.ts";
|
||||
@@ -31,6 +32,9 @@ const executors = {
|
||||
"amazon-q": new KiroExecutor("amazon-q"),
|
||||
codex: new CodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
glm: new GlmExecutor("glm"),
|
||||
"glm-cn": new GlmExecutor("glm-cn"),
|
||||
glmt: new GlmExecutor("glmt"),
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
"azure-openai": new AzureOpenAIExecutor(),
|
||||
gitlab: new GitlabExecutor(),
|
||||
@@ -81,6 +85,7 @@ export { KiroExecutor } from "./kiro.ts";
|
||||
export { CodexExecutor } from "./codex.ts";
|
||||
export { CursorExecutor } from "./cursor.ts";
|
||||
export { DefaultExecutor } from "./default.ts";
|
||||
export { GlmExecutor } from "./glm.ts";
|
||||
export { PollinationsExecutor } from "./pollinations.ts";
|
||||
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
export { OpencodeExecutor } from "./opencode.ts";
|
||||
|
||||
@@ -3996,6 +3996,12 @@ export async function handleChatCore({
|
||||
});
|
||||
if (streamReadiness.ok === false) {
|
||||
const { response: failureResponse, reason } = streamReadiness;
|
||||
const failure = {
|
||||
status: failureResponse.status,
|
||||
message: reason,
|
||||
code: streamReadiness.code,
|
||||
type: streamReadiness.type,
|
||||
};
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
appendRequestLog({
|
||||
model,
|
||||
@@ -4011,7 +4017,7 @@ export async function handleChatCore({
|
||||
claudeCacheMeta: claudePromptCacheLogMeta,
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
persistFailureUsage(failureResponse.status, "stream_readiness_timeout");
|
||||
persistFailureUsage(failureResponse.status, streamReadiness.code);
|
||||
// Do NOT call onStreamFailure — a stream stall is an upstream issue,
|
||||
// not an account/quota failure. Marking the account unavailable here
|
||||
// would lock out legitimate accounts when the upstream hangs.
|
||||
@@ -4019,7 +4025,7 @@ export async function handleChatCore({
|
||||
success: false,
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
errorType: "stream_readiness_timeout",
|
||||
errorType: streamReadiness.type,
|
||||
response: failureResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getModelsByProviderId,
|
||||
getProviderModels,
|
||||
} from "../../config/providerModels.ts";
|
||||
import { buildGlmAnthropicMessagesUrl, buildGlmOpenAIChatUrl } from "../../config/glmProvider.ts";
|
||||
import { supportsToolCalling } from "../../services/modelCapabilities.ts";
|
||||
import { getPricingForModel } from "../../../src/shared/constants/pricing.ts";
|
||||
|
||||
@@ -16,31 +17,70 @@ describe("GLM Coding provider registry surfaces", () => {
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.id).toBe("glm");
|
||||
expect(entry?.alias).toBe("glm");
|
||||
expect(entry?.format).toBe("claude");
|
||||
expect(entry?.baseUrl).toBe("https://api.z.ai/api/anthropic/v1/messages");
|
||||
expect(entry?.format).toBe("openai");
|
||||
expect(entry?.executor).toBe("glm");
|
||||
expect(entry?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4/chat/completions");
|
||||
expect(entry?.authType).toBe("apikey");
|
||||
expect(entry?.authHeader).toBe("x-api-key");
|
||||
expect(entry?.headers?.["Anthropic-Version"]).toBe("2023-06-01");
|
||||
expect(entry?.authHeader).toBe("bearer");
|
||||
expect(entry?.headers?.["Anthropic-Version"]).toBeUndefined();
|
||||
expect(entry?.requestDefaults).toEqual({ maxTokens: 16384 });
|
||||
expect(entry?.timeoutMs).toBe(900000);
|
||||
});
|
||||
|
||||
it("registers GLMT as an explicit high-budget preset over the GLM transport", () => {
|
||||
it("preserves custom GLM base URL query parameters while deriving transport endpoints", () => {
|
||||
const providerSpecificData = {
|
||||
baseUrl:
|
||||
"https://proxy.example/glm/api/coding/paas/v4/chat/completions?tenant=alpha&route=glm",
|
||||
};
|
||||
|
||||
expect(buildGlmOpenAIChatUrl(providerSpecificData)).toBe(
|
||||
"https://proxy.example/glm/api/coding/paas/v4/chat/completions?tenant=alpha&route=glm"
|
||||
);
|
||||
expect(
|
||||
buildGlmAnthropicMessagesUrl({
|
||||
anthropicBaseUrl:
|
||||
"https://proxy.example/glm/api/anthropic/v1/messages?tenant=alpha&route=glm",
|
||||
})
|
||||
).toBe("https://proxy.example/glm/api/anthropic/v1/messages?tenant=alpha&route=glm&beta=true");
|
||||
});
|
||||
|
||||
it("registers GLMT as an explicit high-budget preset over the dual GLM transport", () => {
|
||||
const entry = getRegistryEntry("glmt");
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.id).toBe("glmt");
|
||||
expect(entry?.alias).toBe("glmt");
|
||||
expect(entry?.format).toBe("claude");
|
||||
expect(entry?.baseUrl).toBe("https://api.z.ai/api/anthropic/v1/messages");
|
||||
expect(entry?.format).toBe("openai");
|
||||
expect(entry?.executor).toBe("glm");
|
||||
expect(entry?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4/chat/completions");
|
||||
expect(entry?.authType).toBe("apikey");
|
||||
expect(entry?.authHeader).toBe("x-api-key");
|
||||
expect(entry?.authHeader).toBe("bearer");
|
||||
expect(entry?.headers?.["Anthropic-Version"]).toBeUndefined();
|
||||
expect(entry?.requestDefaults).toEqual({
|
||||
maxTokens: 65536,
|
||||
temperature: 0.2,
|
||||
thinkingBudgetTokens: 24576,
|
||||
thinkingType: "adaptive",
|
||||
});
|
||||
expect(entry?.timeoutMs).toBe(900000);
|
||||
});
|
||||
|
||||
it("registers GLM China on the same executor and capability surface", () => {
|
||||
const entry = getRegistryEntry("glm-cn");
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.id).toBe("glm-cn");
|
||||
expect(entry?.alias).toBe("glmcn");
|
||||
expect(entry?.format).toBe("openai");
|
||||
expect(entry?.executor).toBe("glm");
|
||||
expect(entry?.baseUrl).toBe("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions");
|
||||
expect(entry?.requestDefaults).toEqual({ maxTokens: 16384 });
|
||||
expect(entry?.timeoutMs).toBe(900000);
|
||||
expect(getProviderModels("glmcn").map((model) => model.id)).toEqual(
|
||||
getProviderModels("glm").map((model) => model.id)
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the same GLM model inventory through registry-derived model helpers", () => {
|
||||
const byProviderId = getModelsByProviderId("glm");
|
||||
const byAlias = getProviderModels("glm");
|
||||
@@ -71,13 +111,15 @@ describe("GLM Coding provider registry surfaces", () => {
|
||||
expect(get("glm-4.5v")?.contextLength).toBe(16000);
|
||||
expect(get("glm-4.5")?.contextLength).toBe(128000);
|
||||
expect(get("glm-4.5-air")?.contextLength).toBe(128000);
|
||||
expect(get("glm-5.1")?.maxOutputTokens).toBe(131072);
|
||||
expect(get("glm-4.6")?.maxOutputTokens).toBe(32768);
|
||||
|
||||
// Models inheriting the 200K provider default
|
||||
expect(get("glm-5")?.contextLength).toBeUndefined();
|
||||
expect(get("glm-5-turbo")?.contextLength).toBeUndefined();
|
||||
expect(get("glm-4.7-flash")?.contextLength).toBeUndefined();
|
||||
expect(get("glm-4.7")?.contextLength).toBeUndefined();
|
||||
expect(get("glm-4.6")?.contextLength).toBeUndefined();
|
||||
// Models with explicit 200K defaults to avoid null capabilities in direct routes.
|
||||
expect(get("glm-5")?.contextLength).toBe(200000);
|
||||
expect(get("glm-5-turbo")?.contextLength).toBe(200000);
|
||||
expect(get("glm-4.7-flash")?.contextLength).toBe(200000);
|
||||
expect(get("glm-4.7")?.contextLength).toBe(200000);
|
||||
expect(get("glm-4.6")?.contextLength).toBe(200000);
|
||||
});
|
||||
|
||||
it("keeps representative GLM Coding models tool-call capable and priced", () => {
|
||||
|
||||
@@ -217,7 +217,7 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi
|
||||
try {
|
||||
const { getCachedSettings } = await import("@/lib/db/readCache");
|
||||
const settings = await getCachedSettings();
|
||||
const category = getProviderCategory(provider);
|
||||
const category = getProviderCategory(provider || "");
|
||||
return buildProviderProfile(category, settings);
|
||||
} catch {
|
||||
return getProviderProfile(provider);
|
||||
@@ -493,7 +493,7 @@ export function getModelLockoutInfo(provider, connectionId, model) {
|
||||
*/
|
||||
export function getAllModelLockouts() {
|
||||
const now = Date.now();
|
||||
const active = [];
|
||||
const active: any[] = [];
|
||||
for (const key of modelLockouts.keys()) {
|
||||
cleanupModelLockKey(key, now);
|
||||
}
|
||||
@@ -616,7 +616,7 @@ export function getProvidersInCooldown(): Array<{
|
||||
provider: string;
|
||||
failureCount: number;
|
||||
cooldownRemainingMs: number | null;
|
||||
lastFailureAt: number;
|
||||
lastFailureAt: number | null;
|
||||
}> {
|
||||
return getAllCircuitBreakerStatuses()
|
||||
.filter((status) => {
|
||||
@@ -880,14 +880,24 @@ export function getQuotaCooldown(backoffLevel = 0) {
|
||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number, reason?: string }}
|
||||
*/
|
||||
export function checkFallbackError(
|
||||
status,
|
||||
errorText,
|
||||
backoffLevel = 0,
|
||||
_model = null,
|
||||
provider = null,
|
||||
headers = null,
|
||||
status: number,
|
||||
errorText: string | null,
|
||||
backoffLevel: number = 0,
|
||||
_model: string | null = null,
|
||||
provider: string | null = null,
|
||||
headers: any = null,
|
||||
profileOverride: ProviderProfile | null = null
|
||||
) {
|
||||
): {
|
||||
shouldFallback: boolean;
|
||||
cooldownMs: number;
|
||||
baseCooldownMs?: number;
|
||||
newBackoffLevel?: number;
|
||||
usedUpstreamRetryHint?: boolean;
|
||||
reason?: string;
|
||||
permanent?: boolean;
|
||||
creditsExhausted?: boolean;
|
||||
dailyQuotaExhausted?: boolean;
|
||||
} {
|
||||
const errorStr = (errorText || "").toString();
|
||||
const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null);
|
||||
const maxBackoffSteps = profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel;
|
||||
@@ -1103,7 +1113,7 @@ export function getUnavailableUntil(cooldownMs) {
|
||||
* Get the earliest rateLimitedUntil from a list of accounts
|
||||
*/
|
||||
export function getEarliestRateLimitedUntil(accounts) {
|
||||
let earliest = null;
|
||||
let earliest: number | null = null;
|
||||
const now = Date.now();
|
||||
for (const acc of accounts) {
|
||||
if (!acc.rateLimitedUntil) continue;
|
||||
@@ -1126,7 +1136,7 @@ export function formatRetryAfter(rateLimitedUntil) {
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
||||
|
||||
@@ -33,11 +33,8 @@ export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"effort-2025-11-24",
|
||||
].join(",");
|
||||
// Keep aligned with CLAUDE_CODE_VERSION in claudeIdentity.ts. The
|
||||
// "(external, sdk-cli)" suffix here distinguishes SDK-driven CC-compat
|
||||
// relays from the native (external, cli) path.
|
||||
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.131";
|
||||
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.131 (external, sdk-cli)";
|
||||
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.137";
|
||||
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.137 (external, sdk-cli)";
|
||||
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0";
|
||||
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
|
||||
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
|
||||
|
||||
@@ -253,11 +253,12 @@ function getTargetProvider(modelStr: string, providerId?: string | null): string
|
||||
return providerId || parsed.provider || parsed.providerAlias || "unknown";
|
||||
}
|
||||
|
||||
function isStreamReadinessTimeoutErrorBody(errorBody: unknown): boolean {
|
||||
function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
|
||||
if (!errorBody || typeof errorBody !== "object") return false;
|
||||
const error = (errorBody as Record<string, unknown>).error;
|
||||
if (!error || typeof error !== "object") return false;
|
||||
return (error as Record<string, unknown>).code === "STREAM_READINESS_TIMEOUT";
|
||||
const code = (error as Record<string, unknown>).code;
|
||||
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
|
||||
}
|
||||
|
||||
function toRecordedTarget(target: ResolvedComboTarget) {
|
||||
@@ -2124,8 +2125,9 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
|
||||
const isStreamReadinessTimeout =
|
||||
result.status === 504 && isStreamReadinessTimeoutErrorBody(errorBody);
|
||||
const isStreamReadinessFailure =
|
||||
(result.status === 502 || result.status === 504) &&
|
||||
isStreamReadinessFailureErrorBody(errorBody);
|
||||
|
||||
// Fix #1681: Status 499 means client disconnected — stop combo loop immediately.
|
||||
// There is no point trying fallback models when nobody is listening.
|
||||
@@ -2157,13 +2159,13 @@ export async function handleComboChat({
|
||||
);
|
||||
|
||||
// Trigger shared provider circuit breaker for 5xx errors and connection failures
|
||||
if (isProviderFailureCode(result.status)) {
|
||||
if (!isStreamReadinessFailure && isProviderFailureCode(result.status)) {
|
||||
recordProviderFailure(provider, log, target.connectionId, profile);
|
||||
}
|
||||
|
||||
// Check if this is a transient error worth retrying on same model
|
||||
const isTransient =
|
||||
!isStreamReadinessTimeout && [408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
!isStreamReadinessFailure && [408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient) {
|
||||
continue; // Retry same model
|
||||
}
|
||||
@@ -2432,8 +2434,12 @@ async function handleRoundRobinCombo({
|
||||
if (text) {
|
||||
errorText = text.substring(0, 500);
|
||||
errorBody = JSON.parse(text);
|
||||
const parsedError = errorBody?.error;
|
||||
errorText =
|
||||
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||
(typeof parsedError === "object" && parsedError?.message) ||
|
||||
(typeof parsedError === "string" ? parsedError : null) ||
|
||||
errorBody?.message ||
|
||||
errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
}
|
||||
} catch {
|
||||
@@ -2474,8 +2480,9 @@ async function handleRoundRobinCombo({
|
||||
}
|
||||
}
|
||||
|
||||
const isStreamReadinessTimeout =
|
||||
result.status === 504 && isStreamReadinessTimeoutErrorBody(errorBody);
|
||||
const isStreamReadinessFailure =
|
||||
(result.status === 502 || result.status === 504) &&
|
||||
isStreamReadinessFailureErrorBody(errorBody);
|
||||
|
||||
// Round-robin uses the same target-level fallback rule as other combo
|
||||
// strategies: non-ok target responses fall through to the next target.
|
||||
@@ -2498,7 +2505,11 @@ async function handleRoundRobinCombo({
|
||||
);
|
||||
|
||||
// Transient errors → mark in semaphore so round-robin stops stampeding this target.
|
||||
if (TRANSIENT_FOR_SEMAPHORE.includes(result.status) && cooldownMs > 0) {
|
||||
if (
|
||||
!isStreamReadinessFailure &&
|
||||
TRANSIENT_FOR_SEMAPHORE.includes(result.status) &&
|
||||
cooldownMs > 0
|
||||
) {
|
||||
semaphore.markRateLimited(semaphoreKey, cooldownMs);
|
||||
log.warn("COMBO-RR", `${modelStr} error ${result.status}, cooldown ${cooldownMs}ms`);
|
||||
}
|
||||
@@ -2512,7 +2523,7 @@ async function handleRoundRobinCombo({
|
||||
|
||||
// Transient error → retry same model
|
||||
const isTransient =
|
||||
!isStreamReadinessTimeout && [408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
!isStreamReadinessFailure && [408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
normalizeCloudCodePlatform,
|
||||
} from "./cloudCodeHeaders.ts";
|
||||
|
||||
export const GEMINI_CLI_VERSION = "0.40.1";
|
||||
export const GEMINI_CLI_VERSION = "0.41.2";
|
||||
export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1";
|
||||
|
||||
const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ProviderRequestDefaults {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
thinkingBudgetTokens?: number;
|
||||
thinkingType?: "enabled" | "adaptive";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
|
||||
@@ -107,6 +107,10 @@ type UsageQuota = {
|
||||
resetAt: string | null;
|
||||
unlimited: boolean;
|
||||
displayName?: string;
|
||||
details?: Array<{
|
||||
name: string;
|
||||
used: number;
|
||||
}>;
|
||||
currency?: string;
|
||||
grantedBalance?: number;
|
||||
toppedUpBalance?: number;
|
||||
@@ -126,6 +130,32 @@ function toNumber(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toPercentage(value: unknown): number {
|
||||
return Math.max(0, Math.min(100, toNumber(value, 0)));
|
||||
}
|
||||
|
||||
function toTitleCase(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getGlmTokenQuotaName(
|
||||
limit: JsonRecord,
|
||||
existingQuotas: Record<string, UsageQuota>
|
||||
): string {
|
||||
const unit = toNumber(limit.unit, 0);
|
||||
const number = toNumber(limit.number, 0);
|
||||
|
||||
if (unit === 3 && number === 5) return "session";
|
||||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||||
|
||||
return existingQuotas.session ? "weekly" : "session";
|
||||
}
|
||||
|
||||
function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown {
|
||||
const obj = toRecord(source);
|
||||
return obj[snakeKey] ?? obj[camelKey] ?? null;
|
||||
@@ -580,33 +610,71 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (toNumber(json.code, 200) === 401 || json.success === false) {
|
||||
throw new Error("Invalid API key");
|
||||
}
|
||||
|
||||
const data = toRecord(json.data);
|
||||
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
const src = toRecord(limit);
|
||||
const label = getGlmQuotaLabel(src.type, src.unit);
|
||||
if (!label) continue;
|
||||
|
||||
const usedPercent = toNumber(src.percentage, 0);
|
||||
const type = String(src.type || "").toUpperCase();
|
||||
const resetMs = toNumber(src.nextResetTime, 0);
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
|
||||
|
||||
quotas[label] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
unlimited: false,
|
||||
};
|
||||
if (type === "TOKENS_LIMIT") {
|
||||
const quotaName = getGlmTokenQuotaName(src, quotas);
|
||||
const usedPercent = toPercentage(src.percentage);
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
quotas[quotaName] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "TIME_LIMIT") {
|
||||
const total = toNumber(src.usage, toNumber(src.total, 0));
|
||||
const remaining = toNumber(src.remaining, Math.max(0, 100 - toPercentage(src.percentage)));
|
||||
const used = toNumber(src.currentValue, Math.max(0, total - remaining));
|
||||
const remainingPercentage =
|
||||
total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 0;
|
||||
|
||||
quotas["mcp_monthly"] = {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: "Monthly",
|
||||
details: Array.isArray(src.usageDetails)
|
||||
? src.usageDetails.map((item) => {
|
||||
const detail = toRecord(item);
|
||||
return {
|
||||
name: String(detail.modelCode || detail.name || "usage"),
|
||||
used: toNumber(detail.usage, 0),
|
||||
};
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const levelRaw = typeof data.level === "string" ? data.level : "";
|
||||
const plan = levelRaw
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "Unknown";
|
||||
const levelRaw =
|
||||
typeof data.planName === "string"
|
||||
? data.planName
|
||||
: typeof data.level === "string"
|
||||
? data.level
|
||||
: "";
|
||||
const plan = levelRaw ? toTitleCase(levelRaw.replace(/\s*plan$/i, "")) : null;
|
||||
|
||||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||||
}
|
||||
@@ -807,9 +875,12 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
case "qoder":
|
||||
return await getQoderUsage(accessToken);
|
||||
case "glm":
|
||||
case "zai":
|
||||
case "glm-cn":
|
||||
case "glmt":
|
||||
return await getGlmUsage(apiKey, providerSpecificData);
|
||||
return await getGlmUsage(apiKey, {
|
||||
...(providerSpecificData || {}),
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
});
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return await getMiniMaxUsage(apiKey, provider);
|
||||
|
||||
@@ -89,8 +89,8 @@ export const DEFAULT_SAFETY_SETTINGS = [
|
||||
];
|
||||
|
||||
// Convert OpenAI content to Gemini parts
|
||||
export function convertOpenAIContentToParts(content) {
|
||||
const parts = [];
|
||||
export function convertOpenAIContentToParts(content: any) {
|
||||
const parts: any[] = [];
|
||||
|
||||
if (typeof content === "string") {
|
||||
parts.push({ text: content });
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createRequire } from "module";
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const DB_KEY = "cursorupdate.lastUpdatedAndShown.version";
|
||||
const FALLBACK_VERSION = "3.2.14";
|
||||
const FALLBACK_VERSION = "3.3";
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ type StreamReadinessLogger = {
|
||||
|
||||
export type StreamReadinessResult =
|
||||
| { ok: true; response: Response }
|
||||
| { ok: false; response: Response; reason: string };
|
||||
| { ok: false; response: Response; reason: string; code: string; type: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -88,13 +88,18 @@ export function hasUsefulStreamContent(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function createErrorResponse(status: number, message: string): Response {
|
||||
function createErrorResponse(
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
type: string
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
type: "stream_timeout",
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type,
|
||||
code,
|
||||
},
|
||||
}),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
@@ -183,7 +188,14 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
response: createErrorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, reason),
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
reason,
|
||||
"STREAM_READINESS_TIMEOUT",
|
||||
"stream_timeout"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,7 +212,14 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
response: createErrorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, reason),
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
reason,
|
||||
"STREAM_READINESS_TIMEOUT",
|
||||
"stream_timeout"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,7 +232,14 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
response: createErrorResponse(HTTP_STATUS.BAD_GATEWAY, reason),
|
||||
code: "STREAM_EARLY_EOF",
|
||||
type: "stream_early_eof",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
reason,
|
||||
"STREAM_EARLY_EOF",
|
||||
"stream_early_eof"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,19 @@ function parseEnvFile(filePath) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim());
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
|
||||
@@ -111,10 +111,17 @@ function parseEnvEntry(line) {
|
||||
if (eqIndex < 1) return null;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
const value = unquoteEnvValue(trimmed.slice(eqIndex + 1).trim());
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
function parseExampleEntries(content, scope = "full") {
|
||||
const entries = new Map();
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
@@ -187,14 +187,17 @@ export default function ApiManagerPageClient() {
|
||||
for (const key of apiKeys) {
|
||||
// Match analytics entry by unique API Key ID (isolates usage to this specific key instance)
|
||||
const matches = byApiKey.filter((entry: any) => entry.apiKeyId === key.id);
|
||||
const totalRequests = matches.reduce((sum: number, entry: any) => sum + (Number(entry.requests) || 0), 0);
|
||||
const totalRequests = matches.reduce(
|
||||
(sum: number, entry: any) => sum + (Number(entry.requests) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Match call logs by unique ID as well for the lastUsed timestamp
|
||||
const lastUsed =
|
||||
(logs || []).find((log: any) => log.apiKeyId === key.id)?.timestamp || null;
|
||||
(logs || []).find(
|
||||
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
|
||||
)?.timestamp || null;
|
||||
(logs || []).find(
|
||||
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
|
||||
)?.timestamp || null;
|
||||
|
||||
stats[key.id] = {
|
||||
totalRequests,
|
||||
@@ -358,7 +361,7 @@ export default function ApiManagerPageClient() {
|
||||
expiresAt: string | null,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null,
|
||||
rateLimits: Array<{ limit: number; window: number }> | null
|
||||
rateLimits: Array<{ limit: number; window: number }> | null,
|
||||
scopes: string[]
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
@@ -971,7 +974,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
expiresAt: string | null,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null,
|
||||
rateLimits: Array<{ limit: number; window: number }> | null
|
||||
rateLimits: Array<{ limit: number; window: number }> | null,
|
||||
scopes: string[]
|
||||
) => void;
|
||||
}) {
|
||||
@@ -1141,7 +1144,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
expiresAt || null,
|
||||
maxSessions,
|
||||
schedule,
|
||||
rateLimits.length > 0 ? rateLimits : null
|
||||
rateLimits.length > 0 ? rateLimits : null,
|
||||
manageEnabled ? ["manage"] : []
|
||||
);
|
||||
}, [
|
||||
@@ -1329,7 +1332,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
value={String(rl.limit)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
setRateLimits(prev => {
|
||||
setRateLimits((prev) => {
|
||||
const next = [...prev];
|
||||
next[index].limit = val;
|
||||
return next;
|
||||
@@ -1344,7 +1347,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
value={String(rl.window)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
setRateLimits(prev => {
|
||||
setRateLimits((prev) => {
|
||||
const next = [...prev];
|
||||
next[index].window = val;
|
||||
return next;
|
||||
@@ -1355,7 +1358,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<span className="text-sm text-text-muted shrink-0">sec</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRateLimits(prev => prev.filter((_, i) => i !== index))}
|
||||
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
|
||||
title="Remove limit"
|
||||
>
|
||||
@@ -1522,6 +1525,24 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
Immediately revoke all access. Used for suspected abuse or compromised keys.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={keyIsBanned}
|
||||
onClick={() => setKeyIsBanned((prev) => !prev)}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
|
||||
keyIsBanned
|
||||
? "bg-red-500 text-white shadow-sm"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:bg-black/10 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsBanned ? "block" : "check_circle"}
|
||||
</span>
|
||||
{keyIsBanned ? "Banned" : "Active"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Management API Access Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -1551,7 +1572,9 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Expiration Date</p>
|
||||
<p className="text-xs text-text-muted">Key will automatically stop working after this date.</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Key will automatically stop working after this date.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -1563,7 +1586,16 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Management Access */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management Access</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this API key to manage OmniRoute configuration.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={manageEnabled}
|
||||
onClick={() => setManageEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
|
||||
@@ -3080,7 +3080,9 @@ export default function ProviderDetailPage() {
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
@@ -3164,122 +3166,124 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0">
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={
|
||||
gi === groupKeys.length - 1 && index === groupConns.length - 1
|
||||
}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) =>
|
||||
handleToggleRateLimit(conn.id, enabled)
|
||||
}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => setShowOAuthModal(true, conn)
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue(
|
||||
[conn.name, conn.email],
|
||||
emailsVisible,
|
||||
conn.id
|
||||
),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={
|
||||
gi === groupKeys.length - 1 && index === groupConns.length - 1
|
||||
}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) =>
|
||||
handleToggleRateLimit(conn.id, enabled)
|
||||
}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => setShowOAuthModal(true, conn)
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue(
|
||||
[conn.name, conn.email],
|
||||
emailsVisible,
|
||||
conn.id
|
||||
),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -5668,6 +5672,10 @@ function getProviderBaseUrlPlaceholder(providerId?: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function isGlmProvider(providerId?: string | null) {
|
||||
return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt";
|
||||
}
|
||||
|
||||
function parseRoutingTagsInput(value: string): string[] | undefined {
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
@@ -5723,7 +5731,7 @@ function AddApiKeyModal({
|
||||
const defaultBaseUrl = getProviderBaseUrlDefault(provider);
|
||||
const isVertex = provider === "vertex" || provider === "vertex-partner";
|
||||
const defaultRegion = "us-central1";
|
||||
const isGlm = provider === "glm" || provider === "glmt";
|
||||
const isGlm = isGlmProvider(provider);
|
||||
const isQoder = provider === "qoder";
|
||||
const isCloudflare = provider === "cloudflare-ai";
|
||||
const localProviderMetadata = getLocalProviderMetadata(provider);
|
||||
@@ -6225,7 +6233,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const usesBaseUrl = isBaseUrlConfigurableProvider(connection?.provider);
|
||||
const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider);
|
||||
const isVertex = connection?.provider === "vertex" || connection?.provider === "vertex-partner";
|
||||
const isGlm = connection?.provider === "glm" || connection?.provider === "glmt";
|
||||
const isGlm = isGlmProvider(connection?.provider);
|
||||
const isCloudflare = connection?.provider === "cloudflare-ai";
|
||||
const isCodex = connection?.provider === "codex";
|
||||
const isClaude = connection?.provider === "claude";
|
||||
|
||||
@@ -11,6 +11,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
const planVariants = {
|
||||
free: "default",
|
||||
lite: "primary",
|
||||
pro: "primary",
|
||||
ultra: "success",
|
||||
enterprise: "info",
|
||||
|
||||
@@ -64,6 +64,7 @@ const TIER_FILTERS = [
|
||||
{ key: "ultra", labelKey: "tierUltra" },
|
||||
{ key: "pro", labelKey: "tierPro" },
|
||||
{ key: "plus", labelKey: "tierPlus" },
|
||||
{ key: "lite", label: "Lite" },
|
||||
{ key: "free", labelKey: "tierFree" },
|
||||
{ key: "unknown", labelKey: "tierUnknown" },
|
||||
];
|
||||
@@ -349,6 +350,7 @@ export default function ProviderLimits() {
|
||||
ultra: 0,
|
||||
pro: 0,
|
||||
plus: 0,
|
||||
lite: 0,
|
||||
free: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
@@ -520,7 +522,7 @@ export default function ProviderLimits() {
|
||||
color: active ? "var(--color-primary, #E54D5E)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>{t(tier.labelKey)}</span>
|
||||
<span>{tier.label || t(tier.labelKey)}</span>
|
||||
<span className="opacity-85">{tierCounts[tier.key] || 0}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -632,8 +634,11 @@ export default function ProviderLimits() {
|
||||
const remainingPercentage = Math.round(remainingPercentageRaw);
|
||||
const colors = getBarColor(remainingPercentage);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = formatQuotaLabel(q.name);
|
||||
const shortName = q.displayName || formatQuotaLabel(q.name);
|
||||
const staleAfterReset = q.staleAfterReset === true;
|
||||
const details = Array.isArray(q.details)
|
||||
? q.details.filter((detail) => detail && detail.used > 0)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -674,6 +679,16 @@ export default function ProviderLimits() {
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{details.length > 0 ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
{details
|
||||
.map(
|
||||
(detail) => `${formatQuotaLabel(detail.name)} ${detail.used}`
|
||||
)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Countdown */}
|
||||
{staleAfterReset ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
@@ -796,7 +811,10 @@ export default function ProviderLimits() {
|
||||
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
|
||||
{t("noAccountsForTierFilter")}{" "}
|
||||
<strong>
|
||||
{t(TIER_FILTERS.find((tier) => tier.key === tierFilter)?.labelKey || "tierUnknown")}
|
||||
{(() => {
|
||||
const tier = TIER_FILTERS.find((tier) => tier.key === tierFilter);
|
||||
return tier?.label || t(tier?.labelKey || "tierUnknown");
|
||||
})()}
|
||||
</strong>
|
||||
.
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,16 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
agentic_request_freetrial: "Agentic (Trial)",
|
||||
credits: "AI Credits",
|
||||
models: "Models",
|
||||
"5 Hours Quota": "5 Hours",
|
||||
"Weekly Quota": "Weekly",
|
||||
"Monthly Tools": "Monthly Tools",
|
||||
tokens: "Tokens",
|
||||
time_limit: "Time Limit",
|
||||
mcp_monthly: "Monthly",
|
||||
"search-prime": "Web Search",
|
||||
"web-reader": "Web Reader",
|
||||
zread: "Zread",
|
||||
};
|
||||
|
||||
const GLM_QUOTA_ORDER: Record<string, number> = {
|
||||
session: 0,
|
||||
weekly: 1,
|
||||
mcp_monthly: 2,
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -202,9 +207,10 @@ export function parseQuotaData(provider, data) {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
|
||||
const normalizedQuotas = [];
|
||||
const providerId = String(provider || "").toLowerCase();
|
||||
|
||||
try {
|
||||
switch (provider.toLowerCase()) {
|
||||
switch (providerId) {
|
||||
case "github":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
@@ -216,6 +222,21 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "glm-cn":
|
||||
case "glmt":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
displayName: quota?.displayName,
|
||||
details: Array.isArray(quota?.details) ? quota.details : undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "antigravity":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
@@ -361,6 +382,14 @@ export function parseQuotaData(provider, data) {
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "glm" || providerId === "glm-cn" || providerId === "glmt") {
|
||||
normalizedQuotas.sort((a, b) => {
|
||||
const orderA = GLM_QUOTA_ORDER[a.name] ?? 99;
|
||||
const orderB = GLM_QUOTA_ORDER[b.name] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedQuotas;
|
||||
}
|
||||
|
||||
@@ -392,7 +421,7 @@ export function resolvePlanValue(plan, providerSpecificData) {
|
||||
|
||||
/**
|
||||
* Normalize provider-specific plan labels into a shared tier taxonomy.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, plus, free, unknown.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown.
|
||||
*/
|
||||
export function normalizePlanTier(plan) {
|
||||
const raw = typeof plan === "string" ? plan.trim() : "";
|
||||
@@ -455,10 +484,18 @@ export function normalizePlanTier(plan) {
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("MAX")) {
|
||||
return { key: "ultra", label: "Max", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("PRO") || upper.includes("PREMIUM")) {
|
||||
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("LITE") || upper.includes("LIGHT")) {
|
||||
return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("PLUS") || upper.includes("PAID")) {
|
||||
return { key: "plus", label: "Plus", variant: "success", rank: 2, raw };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
|
||||
import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts";
|
||||
import { getGlmModelsUrl } from "@omniroute/open-sse/config/glmProvider.ts";
|
||||
import {
|
||||
buildGlmCodingHeaders,
|
||||
buildGlmModelsUrl,
|
||||
} from "@omniroute/open-sse/config/glmProvider.ts";
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts";
|
||||
@@ -1551,40 +1554,73 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "glm" || provider === "glmt") {
|
||||
if (provider === "glm" || provider === "glm-cn" || provider === "glmt") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const url = getGlmModelsUrl(connection.providerSpecificData);
|
||||
const token = apiKey || accessToken;
|
||||
const glmProviderSpecificData = {
|
||||
...asRecord(connection.providerSpecificData),
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
};
|
||||
const discoveredTargets = [
|
||||
{
|
||||
transport: "openai" as const,
|
||||
url: buildGlmModelsUrl(glmProviderSpecificData, "openai"),
|
||||
},
|
||||
{
|
||||
transport: "anthropic" as const,
|
||||
url: buildGlmModelsUrl(glmProviderSpecificData, "anthropic"),
|
||||
},
|
||||
];
|
||||
const discoveryTargets = discoveredTargets.filter(
|
||||
(target, index, all) => all.findIndex((other) => other.url === target.url) === index
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await safeOutboundFetch(url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
for (const target of discoveryTargets) {
|
||||
response = await safeOutboundFetch(target.url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers:
|
||||
target.transport === "openai"
|
||||
? token
|
||||
? buildGlmCodingHeaders(token, false)
|
||||
: { "Content-Type": "application/json", Accept: "application/json" }
|
||||
: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(token ? { "x-api-key": token } : {}),
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
});
|
||||
if (response.ok) break;
|
||||
if (response.status === 401 || response.status === 403) break;
|
||||
}
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error);
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
if (response?.status === 401 || response?.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
const fallback = buildDiscoveryFallbackResponse();
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
{ error: `Failed to fetch models: ${response?.status || 502}` },
|
||||
{ status: response?.status || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -269,8 +269,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash FROM api_keys WHERE key = ? OR key_hash = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||
}
|
||||
@@ -459,7 +458,6 @@ async function hashKey(key: string): Promise<string> {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
export async function createApiKey(name: string, machineId: string) {
|
||||
export async function createApiKey(name: string, machineId: string, scopes: string[] = []) {
|
||||
if (!machineId) {
|
||||
throw new Error("machineId is required");
|
||||
@@ -493,7 +491,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
|
||||
0,
|
||||
apiKey.createdAt,
|
||||
apiKey.key.slice(0, 12),
|
||||
await hashKey(apiKey.key)
|
||||
await hashKey(apiKey.key),
|
||||
JSON.stringify(scopes)
|
||||
);
|
||||
setNoLog(apiKey.id, false);
|
||||
@@ -584,8 +582,6 @@ export async function updateApiKeyPermissions(
|
||||
rateLimits: update.rateLimits,
|
||||
isBanned: update.isBanned,
|
||||
expiresAt: update.expiresAt,
|
||||
maxSessions: (update as { maxSessions?: number | null; expiresAt?: string | null })
|
||||
.maxSessions,
|
||||
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
|
||||
scopes: (update as { scopes?: string[] | null }).scopes,
|
||||
};
|
||||
@@ -603,7 +599,6 @@ export async function updateApiKeyPermissions(
|
||||
normalized.rateLimits === undefined &&
|
||||
normalized.isBanned === undefined &&
|
||||
normalized.expiresAt === undefined &&
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined &&
|
||||
(normalized as Record<string, unknown>).scopes === undefined
|
||||
) {
|
||||
@@ -734,7 +729,9 @@ export async function updateApiKeyPermissions(
|
||||
|
||||
// Also invalidate Redis if key_hash is available
|
||||
try {
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as { key_hash: string | null } | undefined;
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as
|
||||
| { key_hash: string | null }
|
||||
| undefined;
|
||||
if (row?.key_hash) {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
@@ -938,7 +935,6 @@ export async function getApiKeyMetadata(
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
ipAllowlist: [],
|
||||
scopes: [],
|
||||
isBanned: false,
|
||||
keyHash: null,
|
||||
scopes: ["manage"],
|
||||
|
||||
@@ -239,7 +239,11 @@ export async function resolveComboForModel(
|
||||
const regex = globToRegex(row.pattern);
|
||||
if (regex.test(modelStr)) {
|
||||
try {
|
||||
return JSON.parse(row.combo_data);
|
||||
const combo = JSON.parse(row.combo_data) as Record<string, unknown>;
|
||||
if (combo.isActive === false) {
|
||||
continue;
|
||||
}
|
||||
return combo;
|
||||
} catch {
|
||||
// Corrupted combo data — skip
|
||||
continue;
|
||||
|
||||
@@ -259,7 +259,10 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
|
||||
contextWindow,
|
||||
maxInputTokens: synced?.limit_input ?? contextWindow,
|
||||
maxOutputTokens:
|
||||
synced?.limit_output ?? spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens,
|
||||
synced?.limit_output ??
|
||||
(typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ??
|
||||
spec?.maxOutputTokens ??
|
||||
MODEL_SPECS.__default__.maxOutputTokens,
|
||||
defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0,
|
||||
thinkingBudgetCap: spec?.thinkingBudgetCap ?? null,
|
||||
thinkingOverhead: spec?.thinkingOverhead ?? null,
|
||||
|
||||
@@ -46,7 +46,7 @@ interface ProviderConnectionLike {
|
||||
|
||||
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"glm",
|
||||
"zai",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -1948,7 +1948,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"claude",
|
||||
"kimi-coding",
|
||||
"glm",
|
||||
"zai",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -1469,7 +1469,16 @@ export const updateKeyPermissionsSchema = z
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
maxSessions: z.number().int().min(0).max(10000).optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
rateLimits: z.union([z.array(z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })).max(50), z.null()]).optional(),
|
||||
rateLimits: z
|
||||
.union([
|
||||
z
|
||||
.array(
|
||||
z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })
|
||||
)
|
||||
.max(50),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -1484,7 +1493,7 @@ export const updateKeyPermissionsSchema = z
|
||||
value.expiresAt === undefined &&
|
||||
value.maxSessions === undefined &&
|
||||
value.accessSchedule === undefined &&
|
||||
value.rateLimits === undefined
|
||||
value.rateLimits === undefined &&
|
||||
value.scopes === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -855,7 +855,7 @@ async function handleSingleModelChat(
|
||||
return result.response;
|
||||
}
|
||||
|
||||
if (result.errorType === "stream_readiness_timeout") {
|
||||
if (result.errorType === "stream_timeout" || result.errorType === "stream_early_eof") {
|
||||
// Stream readiness timeout is an upstream stall, not an account/quota failure.
|
||||
// Do NOT mark the account as unavailable or trip the circuit breaker.
|
||||
return result.response;
|
||||
|
||||
@@ -1489,7 +1489,7 @@ export async function markAccountUnavailable(
|
||||
model,
|
||||
"forbidden",
|
||||
status,
|
||||
effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.unavailable,
|
||||
effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.serviceUnavailable,
|
||||
effectiveProviderProfile
|
||||
);
|
||||
updateProviderConnection(connectionId, {
|
||||
@@ -1505,7 +1505,11 @@ export async function markAccountUnavailable(
|
||||
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
|
||||
}
|
||||
|
||||
const terminalStatus = resolveTerminalConnectionStatus(status, result, providerErrorType);
|
||||
const terminalStatus = resolveTerminalConnectionStatus(
|
||||
status,
|
||||
result as { permanent?: boolean; creditsExhausted?: boolean },
|
||||
providerErrorType
|
||||
);
|
||||
const cooldownMs = terminalStatus ? 0 : rawCooldownMs;
|
||||
|
||||
// ── 404 model-only lockout: connection stays active ──
|
||||
@@ -1605,7 +1609,7 @@ export async function markAccountUnavailable(
|
||||
// NOTE: For permanent bans we disable immediately — no threshold needed,
|
||||
// because a permanent ban (403 "Verify your account" / ToS violation) will
|
||||
// NEVER recover, so retrying is pointless regardless of attempt count.
|
||||
if (result.permanent) {
|
||||
if ((result as { permanent?: boolean }).permanent) {
|
||||
try {
|
||||
const settings = await getCachedSettings();
|
||||
const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false;
|
||||
|
||||
@@ -24,6 +24,7 @@ const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { GEMINI_CLI_VERSION } = await import("../../open-sse/services/geminiCliHeaders.ts");
|
||||
const { getCircuitBreaker, resetAllCircuitBreakers } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts");
|
||||
@@ -653,10 +654,10 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests",
|
||||
assert.match(call.url, /chatgpt\.com\/backend-api\/codex\/responses$/);
|
||||
assert.equal(call.headers.Authorization, "Bearer codex-oauth-token");
|
||||
assert.equal(call.headers.Accept, "text/event-stream");
|
||||
assert.equal(call.headers.Version, "0.125.0");
|
||||
assert.equal(call.headers.Version, "0.130.0");
|
||||
assert.equal(call.headers["Openai-Beta"], "responses=experimental");
|
||||
assert.equal(call.headers["X-Codex-Beta-Features"], "responses_websockets");
|
||||
assert.equal(call.headers["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(call.headers["User-Agent"], "codex-cli/0.130.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(call.headers["x-codex-window-id"], "conv_codex_fingerprint:0");
|
||||
assert.ok(call.headers["x-client-request-id"], "expected Codex request id header");
|
||||
assert.ok(call.headers["x-codex-turn-metadata"], "expected Codex turn metadata header");
|
||||
@@ -936,7 +937,9 @@ test("chat pipeline sends Gemini CLI OAuth requests with native Cloud Code trans
|
||||
assert.equal(generateCall.headers.Accept, "application/json");
|
||||
assert.match(
|
||||
generateCall.headers["User-Agent"],
|
||||
/^GeminiCLI\/0\.40\.1\/gemini-3-flash-preview .* google-api-nodejs-client\/9\.15\.1$/
|
||||
new RegExp(
|
||||
`^GeminiCLI/${GEMINI_CLI_VERSION.replaceAll(".", "\\.")}/gemini-3-flash-preview .* google-api-nodejs-client/9\\.15\\.1$`
|
||||
)
|
||||
);
|
||||
assert.match(generateCall.headers["X-Goog-Api-Client"], /^gl-node\/\d+\.\d+\.\d+$/);
|
||||
assert.equal(generateCall.body.project, "fresh-project");
|
||||
|
||||
@@ -24,7 +24,7 @@ function computeOldFingerprint(firstUserMessageText: string, version: string): s
|
||||
}
|
||||
|
||||
describe("Anthropic billing header fingerprint (#1638)", () => {
|
||||
const ccVersion = "2.1.121";
|
||||
const ccVersion = "2.1.137";
|
||||
|
||||
it("should produce the same fingerprint for different messages (stable)", () => {
|
||||
const fp1 = computeStableFingerprint(ccVersion);
|
||||
|
||||
@@ -64,6 +64,23 @@ test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv strips matching quotes from env values", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "server.env"),
|
||||
'JWT_SECRET="jwt-from-server-env"\nCLAUDE_USER_AGENT="claude-cli/2.1.137 (external, cli)"\n',
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = bootstrapEnv({ quiet: true });
|
||||
|
||||
assert.equal(env.JWT_SECRET, "jwt-from-server-env");
|
||||
assert.equal(env.CLAUDE_USER_AGENT, "claude-cli/2.1.137 (external, cli)");
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
@@ -84,12 +84,12 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
|
||||
"codex",
|
||||
{
|
||||
Authorization: "Bearer token",
|
||||
"User-Agent": "codex-cli/0.125.0 (Windows 10.0.26100; x64)",
|
||||
"User-Agent": "codex-cli/0.130.0 (Windows 10.0.26200; x64)",
|
||||
},
|
||||
{ model: "gpt-5.5", messages: [], stream: true }
|
||||
);
|
||||
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26100; x64)");
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.130.0 (Windows 10.0.26200; x64)");
|
||||
assert.deepEqual(Object.keys(JSON.parse(codex.bodyString)), ["model", "stream", "messages"]);
|
||||
|
||||
const copilot = applyFingerprint(
|
||||
@@ -106,7 +106,7 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
|
||||
Authorization: "Bearer token",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent":
|
||||
"GeminiCLI/0.40.1/gemini-2.5-flash (linux; arm64; terminal) google-api-nodejs-client/9.15.1",
|
||||
"GeminiCLI/0.41.2/gemini-2.5-flash (linux; arm64; terminal) google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.22.2",
|
||||
Accept: "*/*",
|
||||
},
|
||||
|
||||
@@ -156,10 +156,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for
|
||||
assert.equal(standardHeaders.Authorization, "Bearer codex-token");
|
||||
assert.equal(standardHeaders.Accept, "text/event-stream");
|
||||
assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1");
|
||||
assert.equal(standardHeaders.Version, "0.125.0");
|
||||
assert.equal(standardHeaders.Version, "0.130.0");
|
||||
assert.equal(standardHeaders["Openai-Beta"], "responses=experimental");
|
||||
assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets");
|
||||
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.130.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(compactHeaders.Accept, "application/json");
|
||||
});
|
||||
|
||||
@@ -168,13 +168,13 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
|
||||
|
||||
await withEnv(
|
||||
{
|
||||
CODEX_CLIENT_VERSION: "0.125.0",
|
||||
CODEX_CLIENT_VERSION: "0.130.0",
|
||||
CODEX_USER_AGENT: undefined,
|
||||
},
|
||||
() => {
|
||||
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
|
||||
assert.equal(headers.Version, "0.125.0");
|
||||
assert.equal(headers["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(headers.Version, "0.130.0");
|
||||
assert.equal(headers["User-Agent"], "codex-cli/0.130.0 (Windows 10.0.26200; x64)");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
|
||||
},
|
||||
() => {
|
||||
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
|
||||
assert.equal(headers.Version, "0.125.0");
|
||||
assert.equal(headers.Version, "0.130.0");
|
||||
assert.equal(headers["User-Agent"], "custom-codex/9.9.9");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -371,7 +371,7 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code
|
||||
assert.equal(finalBody.project, "old-project");
|
||||
assert.match(finalBody.user_prompt_id, /^agent-/);
|
||||
assert.match(finalBody.request.session_id, /^-\d+$/);
|
||||
assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview /);
|
||||
assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.41\.2\/gemini-3\.1-pro-preview /);
|
||||
assert.equal(finalCall.headers.Accept, "*/*");
|
||||
} finally {
|
||||
setCliCompatProviders([]);
|
||||
|
||||
617
tests/unit/glm-executor.test.ts
Normal file
617
tests/unit/glm-executor.test.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getExecutor } from "../../open-sse/executors/index.ts";
|
||||
import { GlmExecutor } from "../../open-sse/executors/glm.ts";
|
||||
|
||||
function makeSseResponse(lines: string[]): Response {
|
||||
return new Response(lines.join("\n\n") + "\n\n", {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
test("GlmExecutor normalizes GLM coding and Anthropic URLs without duplicating endpoints", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
}),
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4/" },
|
||||
}),
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
},
|
||||
}),
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/api/coding/paas/v4/v1/messages",
|
||||
},
|
||||
}),
|
||||
"https://proxy.example.com/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic" },
|
||||
}),
|
||||
"https://api.z.ai/api/anthropic/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1" },
|
||||
}),
|
||||
"https://api.z.ai/api/anthropic/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
new GlmExecutor("glm-cn").buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
anthropicBaseUrl: "https://open.bigmodel.cn/api/anthropic/v1",
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
}),
|
||||
"https://open.bigmodel.cn/api/anthropic/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 1, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic" },
|
||||
}),
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages" },
|
||||
}),
|
||||
"https://api.z.ai/api/anthropic/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 1, {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages" },
|
||||
}),
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages?beta=true",
|
||||
},
|
||||
}),
|
||||
"https://api.z.ai/api/anthropic/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildCountTokensUrl("glm-5.1", {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/api/anthropic/v1/messages/count_tokens",
|
||||
},
|
||||
}),
|
||||
"https://proxy.example.com/api/anthropic/v1/messages/count_tokens?beta=true"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("glm-5.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl:
|
||||
"https://proxy.example.com/api/coding/paas/v4/chat/completions?tenant=alpha&route=glm",
|
||||
},
|
||||
}),
|
||||
"https://proxy.example.com/api/coding/paas/v4/chat/completions?tenant=alpha&route=glm"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
executor.buildCountTokensUrl("glm-5.1", {
|
||||
providerSpecificData: {
|
||||
anthropicBaseUrl:
|
||||
"https://proxy.example.com/api/anthropic/v1/messages/count_tokens?tenant=alpha&route=glm",
|
||||
},
|
||||
}),
|
||||
"https://proxy.example.com/api/anthropic/v1/messages/count_tokens?tenant=alpha&route=glm&beta=true"
|
||||
);
|
||||
});
|
||||
|
||||
test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic headers", () => {
|
||||
assert.equal(getExecutor("glm") instanceof GlmExecutor, true);
|
||||
assert.equal(getExecutor("glm-cn") instanceof GlmExecutor, true);
|
||||
assert.equal(getExecutor("glmt") instanceof GlmExecutor, true);
|
||||
|
||||
const executor = new GlmExecutor("glm");
|
||||
const codingHeaders = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(codingHeaders.Authorization, "Bearer glm-key");
|
||||
assert.equal(codingHeaders["x-api-key"], undefined);
|
||||
assert.equal(codingHeaders["anthropic-version"], undefined);
|
||||
assert.equal(codingHeaders["anthropic-beta"], undefined);
|
||||
assert.equal(codingHeaders["anthropic-dangerous-direct-browser-access"], undefined);
|
||||
assert.equal(codingHeaders.Accept, "text/event-stream");
|
||||
|
||||
const countTokensHeaders = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
false,
|
||||
null,
|
||||
undefined,
|
||||
"anthropic"
|
||||
);
|
||||
assert.equal(countTokensHeaders["x-api-key"], "glm-key");
|
||||
assert.equal(countTokensHeaders.Authorization, undefined);
|
||||
assert.equal(countTokensHeaders["anthropic-version"], "2023-06-01");
|
||||
|
||||
const anthropicHeaders = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages" },
|
||||
},
|
||||
true,
|
||||
null,
|
||||
undefined,
|
||||
"anthropic"
|
||||
);
|
||||
|
||||
assert.equal(anthropicHeaders["x-api-key"], "glm-key");
|
||||
assert.equal(anthropicHeaders.Authorization, undefined);
|
||||
assert.equal(anthropicHeaders.Accept, "text/event-stream");
|
||||
assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01");
|
||||
assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/);
|
||||
assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true");
|
||||
assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.137 \(external, sdk-cli\)$/);
|
||||
assert.equal(anthropicHeaders["X-Stainless-Lang"], "js");
|
||||
assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node");
|
||||
});
|
||||
|
||||
test("GlmExecutor preserves extra API key rotation", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "primary-key",
|
||||
connectionId: "glm-rotation-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
extraApiKeys: ["extra-key"],
|
||||
},
|
||||
},
|
||||
true,
|
||||
null,
|
||||
undefined,
|
||||
"anthropic"
|
||||
);
|
||||
|
||||
assert.ok(["primary-key", "extra-key"].includes(headers["x-api-key"]));
|
||||
assert.equal(headers.Authorization, undefined);
|
||||
});
|
||||
|
||||
test("GlmExecutor applies GLMT adaptive thinking defaults without mutating caller body", () => {
|
||||
const executor = new GlmExecutor("glmt");
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
|
||||
const transformed = executor.transformRequest("glm-5.1", body, true, {
|
||||
apiKey: "glm-key",
|
||||
}) as any;
|
||||
|
||||
assert.notEqual(transformed, body);
|
||||
assert.equal((body as any).max_tokens, undefined);
|
||||
assert.equal(transformed.max_tokens, 65_536);
|
||||
assert.equal(transformed.temperature, 0.2);
|
||||
assert.deepEqual(transformed.thinking, { type: "adaptive", budget_tokens: 24_576 });
|
||||
});
|
||||
|
||||
test("GlmExecutor applies conservative GLM defaults without mutating caller body", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
|
||||
const transformed = executor.transformRequest("glm-5.1", body, false, {
|
||||
apiKey: "glm-key",
|
||||
}) as any;
|
||||
|
||||
assert.notEqual(transformed, body);
|
||||
assert.equal((body as any).max_tokens, undefined);
|
||||
assert.equal(transformed.max_tokens, 16_384);
|
||||
assert.equal(transformed.temperature, undefined);
|
||||
assert.equal(transformed.thinking, undefined);
|
||||
});
|
||||
|
||||
test("GlmExecutor preserves caller max token settings over GLM defaults", () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_output_tokens: 512,
|
||||
};
|
||||
|
||||
const transformed = executor.transformRequest("glm-5.1", body, false, {
|
||||
apiKey: "glm-key",
|
||||
}) as any;
|
||||
|
||||
assert.deepEqual(transformed, body);
|
||||
assert.equal((transformed as any).max_tokens, undefined);
|
||||
assert.equal((transformed as any).max_output_tokens, 512);
|
||||
});
|
||||
|
||||
test("GlmExecutor count_tokens is best-effort and timeout bounded", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
|
||||
assert.equal(
|
||||
executor.buildCountTokensUrl("glm-5.1", {
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
}),
|
||||
"https://api.z.ai/api/anthropic/v1/messages/count_tokens?beta=true"
|
||||
);
|
||||
assert.equal(executor.getCountTokensTimeoutMs(), 3_000);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: { url: string; body: any; headers: any } | null = null;
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers,
|
||||
};
|
||||
return Response.json({ input_tokens: 42 });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.countTokens({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result?.input_tokens, 42);
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.url, "https://api.z.ai/api/anthropic/v1/messages/count_tokens?beta=true");
|
||||
assert.equal(captured.body.model, "glm-5.1");
|
||||
assert.equal(captured.headers["x-api-key"], "glm-key");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor translates Anthropic streaming fallback to OpenAI SSE", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-5.1","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n',
|
||||
{
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.targetFormat, "openai");
|
||||
assert.equal(result.response.headers.get("content-type"), "text/event-stream");
|
||||
const text = await result.response.text();
|
||||
assert.match(text, /chat\.completion\.chunk/);
|
||||
assert.doesNotMatch(text, /message_start/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor sends OpenAI coding payload first and enables streaming tool chunks", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: { url: string; body: any; headers: any } | null = null;
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers,
|
||||
};
|
||||
return makeSseResponse([
|
||||
'data: {"id":"chatcmpl-glm","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}',
|
||||
"data: [DONE]",
|
||||
]);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(captured?.url, "https://api.z.ai/api/coding/paas/v4/chat/completions");
|
||||
assert.equal(captured?.headers.Authorization, "Bearer glm-key");
|
||||
assert.equal(captured?.headers["x-api-key"], undefined);
|
||||
assert.equal(captured?.headers["anthropic-version"], undefined);
|
||||
assert.equal(captured?.body.tool_stream, true);
|
||||
assert.equal(captured?.body.tools[0].function.name, "get_weather");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor falls back internally to Anthropic transport and returns OpenAI JSON", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: any; headers: any }> = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
return new Response(JSON.stringify({ error: "not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: "msg_glm",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "glm-5.1",
|
||||
content: [{ type: "text", text: "fallback ok" }],
|
||||
stop_reason: "end_turn",
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].url, "https://api.z.ai/api/coding/paas/v4/chat/completions");
|
||||
assert.equal(calls[0].headers.Authorization, "Bearer glm-key");
|
||||
assert.equal(calls[1].url, "https://api.z.ai/api/anthropic/v1/messages?beta=true");
|
||||
assert.equal(calls[1].headers["x-api-key"], "glm-key");
|
||||
assert.equal(calls[1].headers.Authorization, undefined);
|
||||
assert.equal(calls[1].body.messages[0].role, "user");
|
||||
assert.equal(calls[1].body._disableToolPrefix, undefined);
|
||||
assert.equal(result.targetFormat, "openai");
|
||||
|
||||
const json = await result.response.json();
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.content, "fallback ok");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor falls back when primary stream ends before useful content", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (calls.length === 1) {
|
||||
return makeSseResponse(["event: ping", "data: {}"]);
|
||||
}
|
||||
return makeSseResponse([
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-5.1","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}',
|
||||
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"fallback stream ok"}}',
|
||||
'event: message_stop\ndata: {"type":"message_stop"}',
|
||||
]);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
"https://api.z.ai/api/anthropic/v1/messages?beta=true",
|
||||
]);
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.targetFormat, "openai");
|
||||
const text = await result.response.text();
|
||||
assert.match(text, /chat\.completion\.chunk/);
|
||||
assert.match(text, /fallback stream ok/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor preserves non-OK streaming upstream status before readiness", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "invalid api key" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "bad-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["https://api.z.ai/api/coding/paas/v4/chat/completions"]);
|
||||
assert.equal(result.response.status, 401);
|
||||
assert.match(await result.response.text(), /invalid api key/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor translates Anthropic JSON errors to OpenAI-shaped fallback responses", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "bad anthropic fallback" },
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.targetFormat, "openai");
|
||||
assert.equal(result.response.status, 400);
|
||||
const json = await result.response.json();
|
||||
assert.equal(json.error.message, "bad anthropic fallback");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GlmExecutor Anthropic fallback keeps tool names unprefixed", async () => {
|
||||
const executor = new GlmExecutor("glm");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: any; headers: any }> = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers,
|
||||
});
|
||||
if (calls.length === 1) return new Response("upstream down", { status: 502 });
|
||||
return Response.json({
|
||||
id: "msg_tool",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "glm-5.1",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { location: "Madrid" } },
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
usage: { input_tokens: 4, output_tokens: 1 },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.1",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1].body.tools[0].name, "get_weather");
|
||||
assert.equal(calls[1].body.tools[0].name.startsWith("proxy_"), false);
|
||||
assert.equal(calls[1].body._disableToolPrefix, undefined);
|
||||
|
||||
const json = await result.response.json();
|
||||
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(json.choices[0].message.tool_calls[0].function.name, "get_weather");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -58,6 +58,142 @@ test("GLM import uses international coding endpoint when apiRegion is internatio
|
||||
}
|
||||
});
|
||||
|
||||
test("GLM import normalizes custom coding models URLs without duplicating endpoints", async () => {
|
||||
await resetStorage();
|
||||
const cases = [
|
||||
{
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
expectedUrl: "https://api.z.ai/api/coding/paas/v4/models",
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4/models",
|
||||
expectedUrl: "https://api.z.ai/api/coding/paas/v4/models",
|
||||
},
|
||||
];
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenUrls: string[] = [];
|
||||
const connections = [];
|
||||
|
||||
for (const [index, testCase] of cases.entries()) {
|
||||
connections.push(
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `glm-custom-${index}`,
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: testCase.baseUrl },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const expected = cases[seenUrls.length];
|
||||
assert.ok(expected, `unexpected GLM discovery call to ${String(url)}`);
|
||||
assert.equal(String(url), expected.expectedUrl);
|
||||
assert.equal(init.headers.Authorization, "Bearer glm-key");
|
||||
assert.equal(init.headers["x-api-key"], undefined);
|
||||
assert.equal(init.headers["anthropic-version"], undefined);
|
||||
seenUrls.push(String(url));
|
||||
return Response.json({ data: [{ id: "glm-5", name: "GLM 5" }] });
|
||||
};
|
||||
|
||||
try {
|
||||
for (const connection of connections) {
|
||||
const response = await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/providers/${connection.id}/models`),
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
seenUrls,
|
||||
cases.map((testCase) => testCase.expectedUrl)
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GLM import falls back to Anthropic model discovery when coding discovery fails", async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: "glm-discovery-fallback",
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { apiRegion: "international" },
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
seenUrls.push(String(url));
|
||||
if (seenUrls.length === 1) {
|
||||
assert.equal(String(url), "https://api.z.ai/api/coding/paas/v4/models");
|
||||
assert.equal(init.headers.Authorization, "Bearer glm-key");
|
||||
assert.equal(init.headers["x-api-key"], undefined);
|
||||
return new Response(JSON.stringify({ error: "bad gateway" }), { status: 502 });
|
||||
}
|
||||
|
||||
assert.equal(String(url), "https://api.z.ai/api/anthropic/v1/models");
|
||||
assert.equal(init.headers.Authorization, undefined);
|
||||
assert.equal(init.headers["x-api-key"], "glm-key");
|
||||
assert.equal(init.headers["anthropic-version"], "2023-06-01");
|
||||
return Response.json({ data: [{ id: "glm-5.1", name: "GLM 5.1" }] });
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/providers/${connection.id}/models`),
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
provider: "glm",
|
||||
connectionId: connection.id,
|
||||
models: [{ id: "glm-5.1", name: "GLM 5.1" }],
|
||||
source: "api",
|
||||
});
|
||||
assert.deepEqual(seenUrls, [
|
||||
"https://api.z.ai/api/coding/paas/v4/models",
|
||||
"https://api.z.ai/api/anthropic/v1/models",
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GLM import preserves auth failures instead of falling back across transports", async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: "glm-auth-fail",
|
||||
apiKey: "bad-key",
|
||||
providerSpecificData: { apiRegion: "international" },
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
seenUrls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "invalid api key" }), { status: 401 });
|
||||
};
|
||||
|
||||
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, 401);
|
||||
assert.deepEqual(seenUrls, ["https://api.z.ai/api/coding/paas/v4/models"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GLMT import shares the GLM coding models endpoint and surfaces provider metadata correctly", async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
@@ -123,6 +259,38 @@ test("GLM import uses China coding endpoint when apiRegion is china", async () =
|
||||
}
|
||||
});
|
||||
|
||||
test("GLM China provider import uses the specialized GLM discovery path", async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "glm-cn",
|
||||
authType: "apikey",
|
||||
name: "glm-cn-provider",
|
||||
apiKey: "glm-cn-key",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://open.bigmodel.cn/api/coding/paas/v4/models");
|
||||
assert.equal(init.headers.Authorization, "Bearer glm-cn-key");
|
||||
assert.equal(init.headers["x-api-key"], undefined);
|
||||
return Response.json({ data: [{ id: "glm-5", name: "GLM 5" }] });
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await modelsRoute.GET(
|
||||
new Request(`http://localhost/api/providers/${connection.id}/models`),
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as any;
|
||||
assert.equal(body.provider, "glm-cn");
|
||||
assert.deepEqual(body.models, [{ id: "glm-5", name: "GLM 5" }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GLM import defaults to international endpoint when apiRegion is missing", async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
|
||||
@@ -26,12 +26,13 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createCombo(name, model) {
|
||||
async function createCombo(name, model, overrides = {}) {
|
||||
return combosDb.createCombo({
|
||||
name,
|
||||
models: [{ provider: "openai", model }],
|
||||
strategy: "priority",
|
||||
config: { temperature: 0 },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,6 +182,40 @@ test("resolveComboForModel skips corrupted combo payloads and keeps scanning", a
|
||||
assert.equal(resolved.name, "fallback");
|
||||
});
|
||||
|
||||
test("resolveComboForModel skips inactive mapped combos and keeps scanning", async () => {
|
||||
const inactiveCombo = await createCombo("inactive", "gpt-4o", { isActive: false });
|
||||
const fallbackCombo = await createCombo("fallback", "gpt-4o-mini");
|
||||
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-4*",
|
||||
comboId: inactiveCombo.id,
|
||||
priority: 10,
|
||||
});
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: fallbackCombo.id,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const resolved = await mappingsDb.resolveComboForModel("gpt-4o");
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.name, "fallback");
|
||||
});
|
||||
|
||||
test("resolveComboForModel returns null when only matching combo is inactive", async () => {
|
||||
const inactiveCombo = await createCombo("inactive", "gpt-4o", { isActive: false });
|
||||
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-4*",
|
||||
comboId: inactiveCombo.id,
|
||||
});
|
||||
|
||||
const resolved = await mappingsDb.resolveComboForModel("gpt-4o");
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("resolveComboForModel returns null when nothing matches", async () => {
|
||||
const combo = await createCombo("alpha", "gpt-4o");
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ test("paid individual tiers use non-gray badge variants", () => {
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Plus").variant, "success");
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Pro").variant, "success");
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Student").variant, "success");
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Lite").key, "lite");
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Lite").label, "Lite");
|
||||
assert.notEqual(providerLimitUtils.normalizePlanTier("Lite").variant, "default");
|
||||
assert.equal(providerLimitUtils.normalizePlanTier("Free").variant, "default");
|
||||
});
|
||||
|
||||
@@ -64,6 +67,7 @@ test("quota labels normalize session and weekly windows while preserving readabl
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("weekly (7d)"), "Weekly");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("weekly sonnet (7d)"), "Weekly Sonnet");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("code_review"), "Code Review");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("mcp_monthly"), "Monthly");
|
||||
});
|
||||
|
||||
test("MiniMax providers are exposed to the limits dashboard support list", () => {
|
||||
@@ -106,43 +110,17 @@ test("MiniMax quota payloads use generic provider parsing and stale resets still
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel(parsed[1].name), "Weekly");
|
||||
});
|
||||
|
||||
test("Z.AI quota labels render 5h, weekly and monthly tool usage", () => {
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("5 Hours Quota"), "5 Hours");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("Weekly Quota"), "Weekly");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("Monthly Tools"), "Monthly Tools");
|
||||
|
||||
const future = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
const parsed = providerLimitUtils.parseQuotaData("zai", {
|
||||
test("GLM quota rows are ordered by session, weekly, then monthly", () => {
|
||||
const parsed = providerLimitUtils.parseQuotaData("glm", {
|
||||
quotas: {
|
||||
"5 Hours Quota": {
|
||||
used: 15,
|
||||
total: 100,
|
||||
remaining: 85,
|
||||
remainingPercentage: 85,
|
||||
resetAt: future,
|
||||
},
|
||||
"Weekly Quota": {
|
||||
used: 4,
|
||||
total: 100,
|
||||
remaining: 96,
|
||||
remainingPercentage: 96,
|
||||
resetAt: future,
|
||||
},
|
||||
"Monthly Tools": {
|
||||
used: 0,
|
||||
total: 100,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: future,
|
||||
},
|
||||
mcp_monthly: { used: 10, total: 100, remainingPercentage: 90 },
|
||||
weekly: { used: 20, total: 100, remainingPercentage: 80 },
|
||||
session: { used: 30, total: 100, remainingPercentage: 70 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.length, 3);
|
||||
assert.equal(parsed[0].name, "5 Hours Quota");
|
||||
assert.equal(parsed[0].remainingPercentage, 85);
|
||||
assert.equal(parsed[1].name, "Weekly Quota");
|
||||
assert.equal(parsed[1].remainingPercentage, 96);
|
||||
assert.equal(parsed[2].name, "Monthly Tools");
|
||||
assert.equal(parsed[2].remainingPercentage, 100);
|
||||
assert.deepEqual(
|
||||
parsed.map((quota) => quota.name),
|
||||
["session", "weekly", "mcp_monthly"]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ function writeEnvExample(rootDir: string) {
|
||||
"MACHINE_ID_SALT=",
|
||||
"CLAUDE_OAUTH_CLIENT_ID=claude-default",
|
||||
"CODEX_OAUTH_CLIENT_ID=codex-default",
|
||||
'CLAUDE_USER_AGENT="claude-cli/2.1.137 (external, cli)"',
|
||||
"# COMMENTED_KEY=skip-me",
|
||||
"",
|
||||
].join("\n"),
|
||||
@@ -64,13 +65,14 @@ test("syncEnv creates .env from .env.example and generates blank secrets", () =>
|
||||
const result = syncEnv({ rootDir, quiet: true });
|
||||
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
|
||||
|
||||
assert.deepEqual(result, { created: true, added: 6 });
|
||||
assert.deepEqual(result, { created: true, added: 7 });
|
||||
assert.match(envContent, /^JWT_SECRET=.{32,}$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
|
||||
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=.{32,}$/m);
|
||||
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
|
||||
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m);
|
||||
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);
|
||||
assert.match(envContent, /^CLAUDE_USER_AGENT="claude-cli\/2\.1\.137 \(external, cli\)"$/m);
|
||||
assert.doesNotMatch(envContent, /^COMMENTED_KEY=/m);
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir;
|
||||
@@ -98,13 +100,14 @@ test("syncEnv appends only missing keys and preserves existing values", () => {
|
||||
const result = syncEnv({ rootDir, quiet: true });
|
||||
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
|
||||
|
||||
assert.deepEqual(result, { created: false, added: 4 });
|
||||
assert.deepEqual(result, { created: false, added: 5 });
|
||||
assert.match(envContent, /^JWT_SECRET=my-custom-secret-that-should-stay$/m);
|
||||
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=custom-claude$/m);
|
||||
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
|
||||
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=.{32,}$/m);
|
||||
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
|
||||
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);
|
||||
assert.match(envContent, /^CLAUDE_USER_AGENT=claude-cli\/2\.1\.137 \(external, cli\)$/m);
|
||||
assert.match(envContent, /Auto-added by sync-env/);
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir;
|
||||
@@ -112,6 +115,37 @@ test("syncEnv appends only missing keys and preserves existing values", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("syncEnv treats quoted and unquoted values as equivalent", () => {
|
||||
const rootDir = createTempRoot();
|
||||
|
||||
const origDataDir = process.env.DATA_DIR;
|
||||
try {
|
||||
writeEnvExample(rootDir);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, ".env"),
|
||||
[
|
||||
"JWT_SECRET=jwt-secret",
|
||||
"API_KEY_SECRET=api-secret",
|
||||
"STORAGE_ENCRYPTION_KEY=storage-secret",
|
||||
"MACHINE_ID_SALT=machine-salt",
|
||||
"CLAUDE_OAUTH_CLIENT_ID=claude-default",
|
||||
"CODEX_OAUTH_CLIENT_ID=codex-default",
|
||||
'CLAUDE_USER_AGENT="claude-cli/2.1.137 (external, cli)"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
process.env.DATA_DIR = rootDir;
|
||||
const result = syncEnv({ rootDir, quiet: true });
|
||||
|
||||
assert.deepEqual(result, { created: false, added: 0 });
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir;
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("syncEnv is idempotent when .env is already complete", () => {
|
||||
const rootDir = createTempRoot();
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@ test("T20: antigravity config has updated User-Agent and sandbox fallback URL",
|
||||
assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent());
|
||||
});
|
||||
|
||||
test("T20: gemini CLI fingerprint uses 0.40.1 and normalizes darwin to macos", () => {
|
||||
assert.equal(GEMINI_CLI_VERSION, "0.40.1");
|
||||
test("T20: gemini CLI fingerprint uses the current CLI version and normalizes darwin to macos", () => {
|
||||
assert.equal(GEMINI_CLI_VERSION, "0.41.2");
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
assert.match(
|
||||
geminiCliUserAgent("gemini-3-flash"),
|
||||
/^GeminiCLI\/0\.40\.1\/gemini-3-flash \(macos; .+; terminal\) google-api-nodejs-client\/9\.15\.1$/
|
||||
/^GeminiCLI\/0\.41\.2\/gemini-3-flash \(macos; .+; terminal\) google-api-nodejs-client\/9\.15\.1$/
|
||||
);
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
@@ -56,10 +56,10 @@ test("T22: github config exposes dedicated responses endpoint", () => {
|
||||
|
||||
test("T20: codex config advertises current client headers and supported models", () => {
|
||||
const codex = REGISTRY.codex;
|
||||
assert.equal(codex.headers.Version, "0.125.0");
|
||||
assert.equal(codex.headers.Version, "0.130.0");
|
||||
assert.equal(codex.headers["Openai-Beta"], "responses=experimental");
|
||||
assert.equal(codex.headers["X-Codex-Beta-Features"], "responses_websockets");
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.130.0 (Windows 10.0.26200; x64)");
|
||||
assert.ok(codex.models.some((model) => model.id === "gpt-5.5-medium"));
|
||||
assert.ok(!codex.models.some((model) => model.id === "codex-auto-review"));
|
||||
});
|
||||
|
||||
@@ -894,25 +894,32 @@ test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () =
|
||||
data: {
|
||||
level: "pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TIME_LIMIT",
|
||||
usage: 1000,
|
||||
currentValue: 12,
|
||||
remaining: 988,
|
||||
percentage: "1.2",
|
||||
nextResetTime: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
usageDetails: [
|
||||
{ modelCode: "search-prime", usage: 5 },
|
||||
{ modelCode: "web-reader", usage: 7 },
|
||||
{ modelCode: "zread", usage: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 3,
|
||||
usage: 15,
|
||||
currentValue: 85,
|
||||
percentage: "15",
|
||||
number: 5,
|
||||
percentage: "64",
|
||||
nextResetTime: Date.now() + 120_000,
|
||||
},
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 6,
|
||||
percentage: "64",
|
||||
nextResetTime: Date.now() + 604_800_000,
|
||||
},
|
||||
{
|
||||
type: "TIME_LIMIT",
|
||||
unit: 5,
|
||||
percentage: "7",
|
||||
nextResetTime: Date.now() + 300_000,
|
||||
unit: 4,
|
||||
number: 7,
|
||||
percentage: "25",
|
||||
nextResetTime: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
type: "OTHER_LIMIT",
|
||||
@@ -934,21 +941,19 @@ test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () =
|
||||
providerSpecificData: { apiRegion: "invalid-region" },
|
||||
});
|
||||
assert.equal(glm.plan, "Pro");
|
||||
assert.equal(glm.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(glm.quotas["5 Hours Quota"].remaining, 85);
|
||||
assert.equal(glm.quotas["Weekly Quota"].used, 64);
|
||||
assert.equal(glm.quotas["Weekly Quota"].remaining, 36);
|
||||
assert.equal(glm.quotas["Monthly Tools"].used, 7);
|
||||
assert.equal(glm.quotas["Monthly Tools"].remaining, 93);
|
||||
|
||||
const zai: any = await usageService.getUsageForProvider({
|
||||
provider: "zai",
|
||||
apiKey: "glm-key",
|
||||
});
|
||||
assert.equal(zai.plan, "Pro");
|
||||
assert.equal(zai.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(zai.quotas["Weekly Quota"].remaining, 36);
|
||||
assert.equal(zai.quotas["Monthly Tools"].remaining, 93);
|
||||
assert.equal(glm.quotas.session.used, 64);
|
||||
assert.equal(glm.quotas.session.remaining, 36);
|
||||
assert.equal(glm.quotas.weekly.used, 25);
|
||||
assert.equal(glm.quotas.weekly.remaining, 75);
|
||||
assert.equal(glm.quotas.mcp_monthly.used, 12);
|
||||
assert.equal(glm.quotas.mcp_monthly.remaining, 988);
|
||||
assert.equal(glm.quotas.mcp_monthly.remainingPercentage, 99);
|
||||
assert.equal(glm.quotas.mcp_monthly.displayName, "Monthly");
|
||||
assert.deepEqual(glm.quotas.mcp_monthly.details, [
|
||||
{ name: "search-prime", used: 5 },
|
||||
{ name: "web-reader", used: 7 },
|
||||
{ name: "zread", used: 0 },
|
||||
]);
|
||||
|
||||
const glmt: any = await usageService.getUsageForProvider({
|
||||
provider: "glmt",
|
||||
@@ -959,6 +964,28 @@ test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () =
|
||||
assert.equal(glmt.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(glmt.quotas["Weekly Quota"].remaining, 36);
|
||||
|
||||
let glmCnUrl = "";
|
||||
globalThis.fetch = async (url) => {
|
||||
glmCnUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
planName: "Lite Plan",
|
||||
limits: [{ type: "TOKENS_LIMIT", percentage: "64" }],
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
const glmCn: any = await usageService.getUsageForProvider({
|
||||
provider: "glm-cn",
|
||||
apiKey: "glm-cn-key",
|
||||
providerSpecificData: { apiRegion: "international" },
|
||||
});
|
||||
assert.match(glmCnUrl, /open\.bigmodel\.cn/);
|
||||
assert.equal(glmCn.plan, "Lite");
|
||||
assert.equal(glmCn.quotas.session.remaining, 36);
|
||||
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
@@ -1141,8 +1168,8 @@ test("usage service parses Cursor team quotas and clamps on-demand ratio", async
|
||||
assert.equal(calls.length, 3);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.init.headers.Authorization, "Bearer cursor-token");
|
||||
assert.equal(call.init.headers["User-Agent"], "Cursor/3.2.14");
|
||||
assert.equal(call.init.headers["x-cursor-client-version"], "3.2.14");
|
||||
assert.equal(call.init.headers["User-Agent"], "Cursor/3.3");
|
||||
assert.equal(call.init.headers["x-cursor-client-version"], "3.3");
|
||||
}
|
||||
|
||||
assert.equal(usage.plan, "Cursor Team");
|
||||
@@ -1256,9 +1283,9 @@ test("usage helper branches cover reset parsing, GitHub quota math, and plan inf
|
||||
assert.deepEqual(__testing.buildCursorUsageHeaders("cursor-token"), {
|
||||
Authorization: "Bearer cursor-token",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Cursor/3.2.14",
|
||||
"x-cursor-client-version": "3.2.14",
|
||||
"x-cursor-user-agent": "Cursor/3.2.14",
|
||||
"User-Agent": "Cursor/3.3",
|
||||
"x-cursor-client-version": "3.3",
|
||||
"x-cursor-user-agent": "Cursor/3.3",
|
||||
});
|
||||
assert.equal(
|
||||
__testing.getCursorMonthlyRequestLimit(
|
||||
|
||||
Reference in New Issue
Block a user