From 8cf6e4b5df7de9114bdf73ed31ccda4883033b1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 21:01:39 +0900 Subject: [PATCH] fix(plugin-sdk): unblock gateway test surfaces --- extensions/openrouter/index.ts | 1 - src/gateway/auth-rate-limit.test.ts | 7 ++ src/gateway/auth-rate-limit.ts | 4 + src/plugin-sdk/provider-stream.test.ts | 116 +++++++++++++--------- src/plugin-sdk/telegram-command-config.ts | 99 +++++++++++++++++- 5 files changed, 177 insertions(+), 50 deletions(-) diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 741df6e94bd..25c267954af 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -23,7 +23,6 @@ export default definePluginEntry({ const { buildProviderReplayFamilyHooks, buildProviderStreamFamilyHooks, - composeProviderStreamWrappers, createProviderApiKeyAuthMethod, DEFAULT_CONTEXT_TOKENS, getOpenRouterModelCapabilities, diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts index 68fa8c14c9d..0749269db7c 100644 --- a/src/gateway/auth-rate-limit.test.ts +++ b/src/gateway/auth-rate-limit.test.ts @@ -154,6 +154,13 @@ describe("auth rate limiter", () => { }, ); + it("tracks synthetic browser-origin limiter keys independently", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("browser-origin:http://127.0.0.1:18789"); + expect(limiter.check("browser-origin:http://127.0.0.1:18789").allowed).toBe(false); + expect(limiter.check("browser-origin:http://localhost:5173").allowed).toBe(true); + }); + // ---------- loopback exemption ---------- it.each(["127.0.0.1", "::1"])("exempts loopback address %s by default", (ip) => { diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 166c215a5bb..0e631c045f9 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -39,6 +39,7 @@ export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default"; export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret"; export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token"; export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth"; +const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:"; export interface RateLimitEntry { /** Timestamps (epoch ms) of recent failed attempts inside the window. */ @@ -89,6 +90,9 @@ const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute * share one representation (including IPv4-mapped IPv6 forms). */ export function normalizeRateLimitClientIp(ip: string | undefined): string { + if (typeof ip === "string" && ip.startsWith(BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX)) { + return ip; + } return resolveClientIp({ remoteAddr: ip }) ?? "unknown"; } diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 47765a1facc..74942d8efd0 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -5,6 +5,24 @@ import { composeProviderStreamWrappers, } from "./provider-stream.js"; +function requireWrapStreamFn( + wrapStreamFn: ReturnType["wrapStreamFn"], +) { + expect(wrapStreamFn).toBeTypeOf("function"); + if (!wrapStreamFn) { + throw new Error("expected wrapStreamFn to be defined"); + } + return wrapStreamFn; +} + +function requireStreamFn(streamFn: StreamFn | null | undefined) { + expect(streamFn).toBeTypeOf("function"); + if (!streamFn) { + throw new Error("expected wrapped streamFn to be defined"); + } + return streamFn; +} + describe("composeProviderStreamWrappers", () => { it("applies wrappers left to right", async () => { const order: string[] = []; @@ -51,19 +69,17 @@ describe("buildProviderStreamFamilyHooks", () => { >; options?.onPayload?.(payload as never, model as never); capturedPayload = payload; - capturedHeaders = options?.headers as Record | undefined; + capturedHeaders = options?.headers; return {} as never; }; const googleHooks = buildProviderStreamFamilyHooks("google-thinking"); - googleHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - thinkingLevel: "high", - } as never)( - { api: "google-generative-ai", id: "gemini-3.1-pro-preview" } as never, - {} as never, - {}, - ); + void requireStreamFn( + requireWrapStreamFn(googleHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + thinkingLevel: "high", + } as never), + )({ api: "google-generative-ai", id: "gemini-3.1-pro-preview" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "HIGH" } }, }); @@ -73,10 +89,12 @@ describe("buildProviderStreamFamilyHooks", () => { expect(googleThinkingConfig).not.toHaveProperty("thinkingBudget"); const minimaxHooks = buildProviderStreamFamilyHooks("minimax-fast-mode"); - minimaxHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - extraParams: { fastMode: true }, - } as never)( + void requireStreamFn( + requireWrapStreamFn(minimaxHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + extraParams: { fastMode: true }, + } as never), + )( { api: "anthropic-messages", provider: "minimax", @@ -88,26 +106,26 @@ describe("buildProviderStreamFamilyHooks", () => { expect(capturedModelId).toBe("MiniMax-M2.7-highspeed"); const moonshotHooks = buildProviderStreamFamilyHooks("moonshot-thinking"); - moonshotHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - thinkingLevel: "off", - } as never)( - { api: "openai-completions", id: "kimi-k2.5" } as never, - {} as never, - {}, - ); + void requireStreamFn( + requireWrapStreamFn(moonshotHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + thinkingLevel: "off", + } as never), + )({ api: "openai-completions", id: "kimi-k2.5" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, thinking: { type: "disabled" }, }); const openAiHooks = buildProviderStreamFamilyHooks("openai-responses-defaults"); - openAiHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - extraParams: { serviceTier: "flex" }, - config: {}, - agentDir: "/tmp/provider-stream-test", - } as never)( + void requireStreamFn( + requireWrapStreamFn(openAiHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + extraParams: { serviceTier: "flex" }, + config: {}, + agentDir: "/tmp/provider-stream-test", + } as never), + )( { api: "openai-responses", provider: "openai", @@ -124,40 +142,48 @@ describe("buildProviderStreamFamilyHooks", () => { expect(capturedHeaders).toBeDefined(); const openRouterHooks = buildProviderStreamFamilyHooks("openrouter-thinking"); - openRouterHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - thinkingLevel: "high", - modelId: "openai/gpt-5.4", - } as never)({ provider: "openrouter", id: "openai/gpt-5.4" } as never, {} as never, {}); + void requireStreamFn( + requireWrapStreamFn(openRouterHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + thinkingLevel: "high", + modelId: "openai/gpt-5.4", + } as never), + )({ provider: "openrouter", id: "openai/gpt-5.4" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, reasoning: { effort: "high" }, }); - openRouterHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - thinkingLevel: "high", - modelId: "x-ai/grok-3", - } as never)({ provider: "openrouter", id: "x-ai/grok-3" } as never, {} as never, {}); + void requireStreamFn( + requireWrapStreamFn(openRouterHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + thinkingLevel: "high", + modelId: "x-ai/grok-3", + } as never), + )({ provider: "openrouter", id: "x-ai/grok-3" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, }); expect(capturedPayload).not.toHaveProperty("reasoning"); const toolStreamHooks = buildProviderStreamFamilyHooks("tool-stream-default-on"); - toolStreamHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - extraParams: {}, - } as never)({ id: "glm-4.7" } as never, {} as never, {}); + void requireStreamFn( + requireWrapStreamFn(toolStreamHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + extraParams: {}, + } as never), + )({ id: "glm-4.7" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, tool_stream: true, }); - toolStreamHooks.wrapStreamFn?.({ - streamFn: baseStreamFn, - extraParams: { tool_stream: false }, - } as never)({ id: "glm-4.7" } as never, {} as never, {}); + void requireStreamFn( + requireWrapStreamFn(toolStreamHooks.wrapStreamFn)({ + streamFn: baseStreamFn, + extraParams: { tool_stream: false }, + } as never), + )({ id: "glm-4.7" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, }); diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts index 9a3ec2ee276..274e97f57ff 100644 --- a/src/plugin-sdk/telegram-command-config.ts +++ b/src/plugin-sdk/telegram-command-config.ts @@ -26,15 +26,106 @@ type TelegramCommandConfigContract = { }; }; +const FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/; + +function fallbackNormalizeTelegramCommandName(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; + return withoutSlash.trim().toLowerCase().replace(/-/g, "_"); +} + +function fallbackNormalizeTelegramCommandDescription(value: string): string { + return value.trim(); +} + +function fallbackResolveTelegramCustomCommands(params: { + commands?: TelegramCustomCommandInput[] | null; + reservedCommands?: Set; + checkReserved?: boolean; + checkDuplicates?: boolean; +}): { + commands: Array<{ command: string; description: string }>; + issues: TelegramCustomCommandIssue[]; +} { + const entries = Array.isArray(params.commands) ? params.commands : []; + const reserved = params.reservedCommands ?? new Set(); + const checkReserved = params.checkReserved !== false; + const checkDuplicates = params.checkDuplicates !== false; + const seen = new Set(); + const resolved: Array<{ command: string; description: string }> = []; + const issues: TelegramCustomCommandIssue[] = []; + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const normalized = fallbackNormalizeTelegramCommandName(String(entry?.command ?? "")); + if (!normalized) { + issues.push({ + index, + field: "command", + message: "Telegram custom command is missing a command name.", + }); + continue; + } + if (!FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`, + }); + continue; + } + if (checkReserved && reserved.has(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" conflicts with a native command.`, + }); + continue; + } + if (checkDuplicates && seen.has(normalized)) { + issues.push({ + index, + field: "command", + message: `Telegram custom command "/${normalized}" is duplicated.`, + }); + continue; + } + const description = fallbackNormalizeTelegramCommandDescription( + String(entry?.description ?? ""), + ); + if (!description) { + issues.push({ + index, + field: "description", + message: `Telegram custom command "/${normalized}" is missing a description.`, + }); + continue; + } + if (checkDuplicates) { + seen.add(normalized); + } + resolved.push({ command: normalized, description }); + } + + return { commands: resolved, issues }; +} + +const FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT: TelegramCommandConfigContract = { + TELEGRAM_COMMAND_NAME_PATTERN: FALLBACK_TELEGRAM_COMMAND_NAME_PATTERN, + normalizeTelegramCommandName: fallbackNormalizeTelegramCommandName, + normalizeTelegramCommandDescription: fallbackNormalizeTelegramCommandDescription, + resolveTelegramCustomCommands: fallbackResolveTelegramCustomCommands, +}; + function loadTelegramCommandConfigContract(): TelegramCommandConfigContract { const contract = getBundledChannelContractSurfaceModule({ pluginId: "telegram", preferredBasename: "contract-surfaces.ts", }); - if (!contract) { - throw new Error("telegram command config contract surface is unavailable"); - } - return contract; + return contract ?? FALLBACK_TELEGRAM_COMMAND_CONFIG_CONTRACT; } export const TELEGRAM_COMMAND_NAME_PATTERN =