diff --git a/.env.example b/.env.example index 89a97f7eea..0bd7ebd43e 100644 --- a/.env.example +++ b/.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" # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 1a322516fc..7ddc7a86a3 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -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"; diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index af8dbce4a9..ae0bdde920 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -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"; diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 8ca468ed7f..8fbffe72aa 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -3,22 +3,101 @@ import { ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts"; type JsonRecord = Record; 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 { + return { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + Authorization: `Bearer ${apiKey}`, + }; +} + +export function buildGlmBaseHeaders(apiKey: string, stream = true): Record { + 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", + }; +} diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index abeba69013..99cf3386bb 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -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", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ba98449e1..52e769aa19 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -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 = { 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 = { 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], diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 3e929cfc8e..4edc387408 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -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, }); diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 78988660df..832810f841 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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 | null> { void credentials; void log; return null; @@ -379,12 +386,12 @@ export class BaseExecutor { let timeoutId: ReturnType | 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 { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 4f008623fc..7db15a834c 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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; diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts new file mode 100644 index 0000000000..2a347a23f4 --- /dev/null +++ b/open-sse/executors/glm.ts @@ -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; +type GlmExecuteResult = Awaited> & { + 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 { + 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 { + 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 | null, + _model?: string, + transport: GlmTransport = getGlmTransport(credentials.providerSpecificData) + ): Record { + 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 { + 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 | 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 { + 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; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 8d696afac4..03b4d1b079 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ee5aed4dce..fbcde7c28c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.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, }; } diff --git a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts index 3cd7fcb751..f937df26c2 100644 --- a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts +++ b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts @@ -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", () => { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index a7dbae5b6d..446b9510c1 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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`); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 8ef294fe5a..c8439ef118 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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"; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a7e522a5ec..a81d9e9771 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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).error; if (!error || typeof error !== "object") return false; - return (error as Record).code === "STREAM_READINESS_TIMEOUT"; + const code = (error as Record).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; } diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts index 0816a911bf..7c08bb281e 100644 --- a/open-sse/services/geminiCliHeaders.ts +++ b/open-sse/services/geminiCliHeaders.ts @@ -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({ diff --git a/open-sse/services/providerRequestDefaults.ts b/open-sse/services/providerRequestDefaults.ts index 0659582d9b..5a88dd6de2 100644 --- a/open-sse/services/providerRequestDefaults.ts +++ b/open-sse/services/providerRequestDefaults.ts @@ -4,6 +4,7 @@ export interface ProviderRequestDefaults { maxTokens?: number; temperature?: number; thinkingBudgetTokens?: number; + thinkingType?: "enabled" | "adaptive"; } function asRecord(value: unknown): JsonRecord | null { diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index ef4c1b3478..8424b89af2 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 { + 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 = {}; 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); diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index a41ac9a98a..5a2c1f825f 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -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 }); diff --git a/open-sse/utils/cursorVersionDetector.ts b/open-sse/utils/cursorVersionDetector.ts index f42bd54fcd..87c4f1ff89 100644 --- a/open-sse/utils/cursorVersionDetector.ts +++ b/open-sse/utils/cursorVersionDetector.ts @@ -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; diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 3104f09e99..5899d27106 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -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 { 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" + ), }; } diff --git a/scripts/bootstrap-env.mjs b/scripts/bootstrap-env.mjs index c1945b6abb..06e5deaad2 100644 --- a/scripts/bootstrap-env.mjs +++ b/scripts/bootstrap-env.mjs @@ -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 = [ diff --git a/scripts/sync-env.mjs b/scripts/sync-env.mjs index e7da240c62..050282afb7 100644 --- a/scripts/sync-env.mjs +++ b/scripts/sync-env.mjs @@ -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/); diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 747ebcc6e7..2e28626508 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -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({ sec + {/* Management API Access Toggle */}
@@ -1551,7 +1572,9 @@ const PermissionsModal = memo(function PermissionsModal({

Expiration Date

-

Key will automatically stop working after this date.

+

+ Key will automatically stop working after this date. +

- + {/* Management Access */} +
+
+

Management Access

+

+ Allow this API key to manage OmniRoute configuration. +

+
+
) : null}
- {groupKeys.map((tag, gi) => { - const groupConns = groupMap.get(tag)!; - return ( -
0 - ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" - : "" - } - > - {tag && ( -
- - label - - - {tag} - -
- - {groupConns.length} - + {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + {groupConns.length} + +
+ )} +
+ {groupConns.map((conn, index) => ( + 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} + /> + ))}
- )} -
- {groupConns.map((conn, index) => ( - 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} - /> - ))}
-
- - ); - } - })()} -
+ ); + })} +
+ + ); + })() + )} )} @@ -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"; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx index 42c2cec083..12e1479ce8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx @@ -11,6 +11,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; const planVariants = { free: "default", + lite: "primary", pro: "primary", ultra: "success", enterprise: "info", diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index b7c638e28c..5ecbeaab2f 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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)", }} > - {t(tier.labelKey)} + {tier.label || t(tier.labelKey)} {tierCounts[tier.key] || 0} ); @@ -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 (
+ {details.length > 0 ? ( + + {details + .map( + (detail) => `${formatQuotaLabel(detail.name)} ${detail.used}` + ) + .join(" · ")} + + ) : null} + {/* Countdown */} {staleAfterReset ? ( @@ -796,7 +811,10 @@ export default function ProviderLimits() {
{t("noAccountsForTierFilter")}{" "} - {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"); + })()} .
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 7132256c4b..615dd51c61 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -22,11 +22,16 @@ const QUOTA_LABEL_MAP: Record = { 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 = { + session: 0, + weekly: 1, + mcp_monthly: 2, }; function toRecord(value: unknown): Record { @@ -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 }; } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 7b6b8572f5..de67c6487b 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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 } ); } diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 01b8784e60..788d66a4a8 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -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 { 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).maxSessions === undefined (normalized as Record).maxSessions === undefined && (normalized as Record).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"], diff --git a/src/lib/db/modelComboMappings.ts b/src/lib/db/modelComboMappings.ts index bb778c853a..1d11c2fcff 100644 --- a/src/lib/db/modelComboMappings.ts +++ b/src/lib/db/modelComboMappings.ts @@ -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; + if (combo.isActive === false) { + continue; + } + return combo; } catch { // Corrupted combo data — skip continue; diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 94dc5d9610..90612b19f0 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -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, diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index eafe145413..df189ceb5a 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -46,7 +46,7 @@ interface ProviderConnectionLike { const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "glm", - "zai", + "glm-cn", "glmt", "minimax", "minimax-cn", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b9bfd6a479..05dbcb5d46 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1948,7 +1948,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "claude", "kimi-coding", "glm", - "zai", + "glm-cn", "glmt", "minimax", "minimax-cn", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 2c4d3ed2cf..ae5aeb25cb 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 218efd7546..56cbee63d9 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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; diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 1ee4ba227e..5af85cc791 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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; diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 5ff4bfa0de..3837e46ecf 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -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"); diff --git a/tests/unit/anthropic-cache-fingerprint.test.ts b/tests/unit/anthropic-cache-fingerprint.test.ts index 34351665da..110a97e642 100644 --- a/tests/unit/anthropic-cache-fingerprint.test.ts +++ b/tests/unit/anthropic-cache-fingerprint.test.ts @@ -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); diff --git a/tests/unit/bootstrap-env.test.ts b/tests/unit/bootstrap-env.test.ts index 7d20c5d3ad..43e20bfd7c 100644 --- a/tests/unit/bootstrap-env.test.ts +++ b/tests/unit/bootstrap-env.test.ts @@ -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; diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 83042d728d..9a2affbefb 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -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: "*/*", }, diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index efeab8749f..092320f075 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -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"); } ); diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 44c65dfe85..4179c4ab8d 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -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([]); diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts new file mode 100644 index 0000000000..bc5777aa4b --- /dev/null +++ b/tests/unit/glm-executor.test.ts @@ -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; + } +}); diff --git a/tests/unit/glm-provider-model-import-route.test.ts b/tests/unit/glm-provider-model-import-route.test.ts index 4d4fe82dde..02d6eef6f1 100644 --- a/tests/unit/glm-provider-model-import-route.test.ts +++ b/tests/unit/glm-provider-model-import-route.test.ts @@ -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({ diff --git a/tests/unit/model-combo-mappings-db.test.ts b/tests/unit/model-combo-mappings-db.test.ts index 339a6d6b2a..ab19221fbe 100644 --- a/tests/unit/model-combo-mappings-db.test.ts +++ b/tests/unit/model-combo-mappings-db.test.ts @@ -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"); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index 1c272e9431..653fbc1f89 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -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"] + ); }); diff --git a/tests/unit/sync-env.test.ts b/tests/unit/sync-env.test.ts index c20cdc85c2..c47cd0ad7b 100644 --- a/tests/unit/sync-env.test.ts +++ b/tests/unit/sync-env.test.ts @@ -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(); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index 2793572a11..a36b3f65a7 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -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")); }); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index fd8e178167..3b77cff9dd 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -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(