From aa9926334fd6e95104b93aac0b6fb5cb349f3f76 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:37:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(sidecar):=20Retry-After?= =?UTF-8?q?=20=E8=A7=A3=E6=9E=90=E6=94=B6=E6=95=9B=E8=87=B3=20SDK=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E2=80=94=E2=80=94sidecar=20=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E5=B1=82=E5=8E=BB=E9=87=8D=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseRetryAfterHeader/computeRetryDelay/120s 硬上限补进 @lume/agent-sdk 导出面;pi-ai-provider 删除本地重复解析,改用共享实现(delta-seconds 与 HTTP-date 双格式行为不变,SDK retry.test.ts 已覆盖验收格式与 clamp)。 Co-Authored-By: Claude Fable 5 --- apps/sidecar/src/services/model-runtime/pi-ai-provider.ts | 8 ++------ packages/sdk/src/index.ts | 3 +++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts index f77becdbe..8facc30ed 100644 --- a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts +++ b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts @@ -21,6 +21,7 @@ import type { NormalizedMessageParam, NormalizedResponseBlock, } from "@lume/agent-sdk"; +import { parseRetryAfterHeader } from "@lume/agent-sdk"; type PiTextApi = "openai-completions" | "openai-responses" | "openai-codex-responses" | "anthropic-messages" | "google-generative-ai"; @@ -288,12 +289,7 @@ function toResponse(message: AssistantMessage): CreateMessageResponse { } function retryAfterMs(headers: Record | undefined): number | undefined { - const raw = headers?.["retry-after"] ?? headers?.["Retry-After"]; - if (!raw) return undefined; - const seconds = Number(raw); - if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); - const date = Date.parse(raw); - return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined; + return parseRetryAfterHeader(headers?.["retry-after"] ?? headers?.["Retry-After"]); } function structuredOutputTransform(api: PiTextApi, schema: Record | undefined) { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5279bb815..815d56ce3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -396,6 +396,9 @@ export { isRateLimitError, formatApiError, getRetryDelay, + computeRetryDelay, + parseRetryAfterHeader, + MAX_RETRY_AFTER_DELAY_MS, DEFAULT_RETRY_CONFIG, } from './utils/retry.js' export type { RetryConfig } from './utils/retry.js' From cf8ed1cd88e842cf9e96e79d0c33b1c6da194102 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:37:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A5=20chore(sidecar):=20=E6=8B=86?= =?UTF-8?q?=E9=99=A4=20prompt=20=E7=BB=84=E8=A3=85=E5=81=87=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E4=B8=8E=E9=9B=B6=E6=B6=88=E8=B4=B9=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E6=AF=94=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composePromptSections/PromptSection/PromptSectionMode 全仓仅自测试引用,连同 context-budget.ts 的 DEFAULT_CONTEXT_BUDGET 一并删除;context-assembler 保留指纹 trace,budget 元数据收敛为真实 tokenBudget 总额。 Co-Authored-By: Claude Fable 5 --- .../context/context-assembler.ts | 4 +-- .../agent-runtime/context/context-budget.ts | 19 ----------- .../prompt/sections/section-composer.test.ts | 32 ------------------- .../agent/prompt/sections/section-composer.ts | 20 ------------ .../src/services/agent/prompt/types.ts | 10 ------ 5 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 apps/sidecar/src/services/agent-runtime/context/context-budget.ts delete mode 100644 apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts delete mode 100644 apps/sidecar/src/services/agent/prompt/sections/section-composer.ts delete mode 100644 apps/sidecar/src/services/agent/prompt/types.ts diff --git a/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts b/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts index f29aff42d..78f42c03a 100644 --- a/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts +++ b/apps/sidecar/src/services/agent-runtime/context/context-assembler.ts @@ -17,7 +17,6 @@ import type { MemoryV2RecallItem } from "../../memory-v2/types"; import type { CollectedAppendContextEffect } from "../../workflow-hooks/hook-effects"; import { getPermissionDeniedSummary } from "../permissions/permission-denials"; import type { TraceRecorder } from "../trace/trace-recorder"; -import { DEFAULT_CONTEXT_BUDGET, type ContextBudget } from "./context-budget"; import { buildMessageAttachmentBrief } from "./message-attachments"; export interface ContextAssemblyInput { @@ -71,7 +70,7 @@ export interface ContextAssemblyResult { userMessageContentBlocks?: ContentBlockParam[]; sessionContext: string; planContext?: string; - budget: ContextBudget; + budget: { total: number }; trace: { includedMemoryIds: string[]; includedSessionMessageIds: string[]; @@ -341,7 +340,6 @@ Browser annotation bodies are the user's intent. URL, title, DOM locators, selec userMessageForModel, sessionContext: "", budget: { - ...DEFAULT_CONTEXT_BUDGET, total: input.tokenBudget }, trace: { diff --git a/apps/sidecar/src/services/agent-runtime/context/context-budget.ts b/apps/sidecar/src/services/agent-runtime/context/context-budget.ts deleted file mode 100644 index d1b1f2223..000000000 --- a/apps/sidecar/src/services/agent-runtime/context/context-budget.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface ContextBudget { - total: number; - system: number; - dynamic: number; - memory: number; - session: number; - toolSchemas: number; - reservedOutput: number; -} - -export const DEFAULT_CONTEXT_BUDGET: ContextBudget = { - total: 1, - system: 0.25, - dynamic: 0.15, - memory: 0.20, - session: 0.25, - toolSchemas: 0.10, - reservedOutput: 0.05 -}; diff --git a/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts b/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts deleted file mode 100644 index 61bdefe69..000000000 --- a/apps/sidecar/src/services/agent/prompt/sections/section-composer.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { composePromptSections } from "./section-composer"; - -describe("section-composer", () => { - test("filters by mode and renders in priority order", () => { - const prompt = composePromptSections([ - { - id: "later", - title: "Later", - priority: 20, - mode: ["full"], - content: "B" - }, - { - id: "minimal", - title: "Minimal", - priority: 5, - mode: ["minimal"], - content: "skip" - }, - { - id: "first", - title: "First", - priority: 10, - mode: ["full", "minimal"], - content: "A" - } - ], "full"); - - expect(prompt).toBe("## First\n\nA\n\n## Later\n\nB"); - }); -}); diff --git a/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts b/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts deleted file mode 100644 index 02638da6c..000000000 --- a/apps/sidecar/src/services/agent/prompt/sections/section-composer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PromptSection, PromptSectionMode } from "../types"; - -export function renderPromptSection(section: PromptSection): string { - if (section.title) { - return `## ${section.title}\n\n${section.content.trim()}`; - } - return section.content.trim(); -} - -export function composePromptSections( - sections: Array, - mode: PromptSectionMode -): string { - return sections - .filter((section): section is PromptSection => Boolean(section?.content.trim())) - .filter((section) => section.mode.includes(mode)) - .sort((a, b) => a.priority - b.priority) - .map(renderPromptSection) - .join("\n\n"); -} diff --git a/apps/sidecar/src/services/agent/prompt/types.ts b/apps/sidecar/src/services/agent/prompt/types.ts deleted file mode 100644 index 995baff78..000000000 --- a/apps/sidecar/src/services/agent/prompt/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type PromptSectionMode = "full" | "minimal"; - -export interface PromptSection { - id: string; - title?: string; - priority: number; - mode: PromptSectionMode[]; - content: string; - tokenBudget?: number; -} From 796bb2c14921200026c24f6012411bfac0f43060 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:37:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidecar):=20c?= =?UTF-8?q?hannel=20handlers=20=E5=85=A5=E5=8F=82=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20zod=EF=BC=8C=E7=BB=88=E7=BB=93=20RPC=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8F=8C=E8=BD=A8=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit link/wiki 两家族已随功能移除删除(#418/#432),残存的手写 asObject/asString 校验仅剩 channel 家族:DECRYPT_KEY/TEST/SYNC_MODELS/OAUTH_* 全部迁入 schemas.ts 严格 schema,接受/拒绝面与迁移前一致(OAUTH_CANCEL 缺 sessionId 仍为静默 no-op),新增边界 case 单测。 Co-Authored-By: Claude Fable 5 --- apps/sidecar/src/rpc/channel-handlers.ts | 53 ++++++++----------- .../src/rpc/schemas.channel-params.test.ts | 42 +++++++++++++++ apps/sidecar/src/rpc/schemas.ts | 14 +++++ 3 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 apps/sidecar/src/rpc/schemas.channel-params.test.ts diff --git a/apps/sidecar/src/rpc/channel-handlers.ts b/apps/sidecar/src/rpc/channel-handlers.ts index e28994ba6..56800b410 100644 --- a/apps/sidecar/src/rpc/channel-handlers.ts +++ b/apps/sidecar/src/rpc/channel-handlers.ts @@ -12,12 +12,17 @@ import { updateChannel } from "../services/channel/channel-manager"; import type { RpcHandler } from "./types"; -import { asObject, asString, validateInput } from "./validation"; +import { validateInput } from "./validation"; import { + channelIdParamsSchema, channelCreateInputSchema, channelDeleteParamsSchema, channelUpdateParamsSchema, - fetchModelsInputSchema + connectionIdParamsSchema, + fetchModelsInputSchema, + oauthAnswerParamsSchema, + oauthCancelParamsSchema, + oauthSessionIdParamsSchema } from "./schemas"; import { answerConnectionOAuthPrompt, @@ -41,52 +46,36 @@ export function createChannelHandlers(): Record { return { ok: true }; }, [CHANNEL_IPC_CHANNELS.DECRYPT_KEY]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) { - throw new Error("缺少 channelId"); - } - return decryptApiKey(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.DECRYPT_KEY); + return decryptApiKey(input.channelId); }, [CHANNEL_IPC_CHANNELS.TEST]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) { - throw new Error("缺少 channelId"); - } - return testChannel(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.TEST); + return testChannel(input.channelId); }, [CHANNEL_IPC_CHANNELS.TEST_DIRECT]: async (params) => testChannelDirect(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.TEST_DIRECT) as FetchModelsInput), [CHANNEL_IPC_CHANNELS.FETCH_MODELS]: async (params) => fetchModels(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.FETCH_MODELS) as FetchModelsInput), [CHANNEL_IPC_CHANNELS.SYNC_MODELS]: async (params) => { - const payload = asObject(params); - const channelId = asString(payload.channelId); - if (!channelId) throw new Error("缺少 channelId"); - return syncChannelModels(channelId); + const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.SYNC_MODELS); + return syncChannelModels(input.channelId); }, [CHANNEL_IPC_CHANNELS.OAUTH_START]: async (params) => { - const connectionId = asString(asObject(params).connectionId); - if (!connectionId) throw new Error("缺少 connectionId"); - return startConnectionOAuthLogin(connectionId); + const input = validateInput(connectionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_START); + return startConnectionOAuthLogin(input.connectionId); }, [CHANNEL_IPC_CHANNELS.OAUTH_STATUS]: async (params) => { - const sessionId = asString(asObject(params).sessionId); - if (!sessionId) throw new Error("缺少 sessionId"); - return getConnectionOAuthSession(sessionId); + const input = validateInput(oauthSessionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_STATUS); + return getConnectionOAuthSession(input.sessionId); }, [CHANNEL_IPC_CHANNELS.OAUTH_ANSWER]: async (params) => { - const payload = asObject(params); - const sessionId = asString(payload.sessionId); - const promptId = asString(payload.promptId); - const value = asString(payload.value); - if (!sessionId || !promptId) throw new Error("缺少 OAuth prompt 参数"); - return answerConnectionOAuthPrompt(sessionId, promptId, value ?? ""); + const input = validateInput(oauthAnswerParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_ANSWER); + return answerConnectionOAuthPrompt(input.sessionId, input.promptId, input.value ?? ""); }, [CHANNEL_IPC_CHANNELS.OAUTH_CANCEL]: async (params) => { - const sessionId = asString(asObject(params).sessionId); - if (sessionId) cancelConnectionOAuthLogin(sessionId); + const input = validateInput(oauthCancelParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_CANCEL); + if (input.sessionId) cancelConnectionOAuthLogin(input.sessionId); return { ok: true }; }, }; diff --git a/apps/sidecar/src/rpc/schemas.channel-params.test.ts b/apps/sidecar/src/rpc/schemas.channel-params.test.ts new file mode 100644 index 000000000..f0efda30e --- /dev/null +++ b/apps/sidecar/src/rpc/schemas.channel-params.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + channelIdParamsSchema, + connectionIdParamsSchema, + oauthAnswerParamsSchema, + oauthCancelParamsSchema, + oauthSessionIdParamsSchema +} from "./schemas"; + +describe("channel RPC param schemas", () => { + test("channelIdParamsSchema accepts a non-empty channelId", () => { + expect(channelIdParamsSchema.parse({ channelId: "ch-1" })).toEqual({ channelId: "ch-1" }); + }); + + test("channelIdParamsSchema rejects missing and empty channelId", () => { + expect(channelIdParamsSchema.safeParse({}).success).toBe(false); + expect(channelIdParamsSchema.safeParse({ channelId: "" }).success).toBe(false); + expect(channelIdParamsSchema.safeParse({ channelId: 42 }).success).toBe(false); + }); + + test("param schemas reject unknown keys like the sibling channel schemas", () => { + expect(channelIdParamsSchema.safeParse({ channelId: "ch-1", extra: true }).success).toBe(false); + expect(connectionIdParamsSchema.safeParse({ connectionId: "conn-1", channelId: "x" }).success).toBe(false); + }); + + test("oauthSessionIdParamsSchema requires a non-empty sessionId", () => { + expect(oauthSessionIdParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1"); + expect(oauthSessionIdParamsSchema.safeParse({ sessionId: "" }).success).toBe(false); + }); + + test("oauthAnswerParamsSchema keeps value optional and ids required", () => { + expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1" }).value).toBeUndefined(); + expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1", value: "yes" }).value).toBe("yes"); + expect(oauthAnswerParamsSchema.safeParse({ sessionId: "s-1" }).success).toBe(false); + }); + + test("oauthCancelParamsSchema tolerates an absent sessionId (legacy no-op face)", () => { + expect(oauthCancelParamsSchema.parse({})).toEqual({}); + expect(oauthCancelParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1"); + expect(oauthCancelParamsSchema.safeParse({ sessionId: "" }).success).toBe(false); + }); +}); diff --git a/apps/sidecar/src/rpc/schemas.ts b/apps/sidecar/src/rpc/schemas.ts index b2af2f064..1a197bcb8 100644 --- a/apps/sidecar/src/rpc/schemas.ts +++ b/apps/sidecar/src/rpc/schemas.ts @@ -1818,3 +1818,17 @@ export const fetchModelsInputSchema = z.object({ apiFamily: channelApiFamilySchema.optional(), openaiApiMode: channelOpenAiApiModeSchema.optional() }).strict(); + +export const channelIdParamsSchema = z.object({ channelId: idSchema }).strict(); + +export const connectionIdParamsSchema = z.object({ connectionId: idSchema }).strict(); + +export const oauthSessionIdParamsSchema = z.object({ sessionId: idSchema }).strict(); + +export const oauthAnswerParamsSchema = z.object({ + sessionId: idSchema, + promptId: idSchema, + value: z.string().optional() +}).strict(); + +export const oauthCancelParamsSchema = z.object({ sessionId: idSchema.optional() }).strict(); From 7203e03563e54cd18e3a631a6819a14e9c34264c Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 08:59:19 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20fix(sidecar):=20Retry-After?= =?UTF-8?q?=20clamp=20=E7=BB=9F=E4=B8=80=20120s=E2=80=94=E2=80=94=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20sdk=20computeRetryDelay=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E5=8F=8C=20cap=20=E5=B9=B6=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 issue #422 验收口径:clamp 120s,与 sdk withRetry 的 MAX_RETRY_AFTER_DELAY_MS 一致。 --- .../src/services/model-runtime/pi-ai-provider.test.ts | 9 +++++++-- .../sidecar/src/services/model-runtime/pi-ai-provider.ts | 7 +++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts b/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts index 8d1ee53a8..05dcf3f2d 100644 --- a/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts +++ b/apps/sidecar/src/services/model-runtime/pi-ai-provider.test.ts @@ -44,7 +44,7 @@ describe("pi-ai provider retry policy", () => { expect(isRetryablePiAiError(new PiAiProviderError("invalid key", { status: 401 }))).toBe(false); }); - test("uses 1/2/4/8/16 second backoff and caps Retry-After at 30 seconds", () => { + test("uses 1/2/4/8/16 second backoff and caps Retry-After at 120 seconds", () => { const deterministicRandom = () => 0.5; expect([0, 1, 2, 3, 4].map((index) => resolvePiAiRetryDelayMs(new Error("network"), index, deterministicRandom) @@ -53,7 +53,12 @@ describe("pi-ai provider retry policy", () => { new PiAiProviderError("busy", { retryAfterMs: 90_000 }), 0, deterministicRandom, - )).toBe(30_000); + )).toBe(90_000); + expect(resolvePiAiRetryDelayMs( + new PiAiProviderError("busy", { retryAfterMs: 300_000 }), + 0, + deterministicRandom, + )).toBe(120_000); }); test("falls back for model-specific and credential failures but not malformed requests", () => { diff --git a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts index 8facc30ed..b4c3c9cfa 100644 --- a/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts +++ b/apps/sidecar/src/services/model-runtime/pi-ai-provider.ts @@ -21,13 +21,12 @@ import type { NormalizedMessageParam, NormalizedResponseBlock, } from "@lume/agent-sdk"; -import { parseRetryAfterHeader } from "@lume/agent-sdk"; +import { MAX_RETRY_AFTER_DELAY_MS, parseRetryAfterHeader } from "@lume/agent-sdk"; type PiTextApi = "openai-completions" | "openai-responses" | "openai-codex-responses" | "anthropic-messages" | "google-generative-ai"; const DEFAULT_MAX_RETRIES = 5; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; -const MAX_RETRY_AFTER_MS = 30_000; export interface PiAiProviderOptions { apiType: ApiType; @@ -81,7 +80,7 @@ export function resolvePiAiRetryDelayMs(error: unknown, retryIndex: number, rand const base = RETRY_DELAYS_MS[Math.min(retryIndex, RETRY_DELAYS_MS.length - 1)] ?? RETRY_DELAYS_MS.at(-1)!; const jittered = Math.round(base * (0.8 + random() * 0.4)); const retryAfter = typeof (error as { retryAfterMs?: unknown } | null)?.retryAfterMs === "number" - ? Math.min(MAX_RETRY_AFTER_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs)) + ? Math.min(MAX_RETRY_AFTER_DELAY_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs)) : 0; return Math.max(jittered, retryAfter); } @@ -373,7 +372,7 @@ export class PiAiProvider implements LLMProvider { signal: params.abortSignal, maxTokens: params.maxTokens, maxRetries: 0, - maxRetryDelayMs: MAX_RETRY_AFTER_MS, + maxRetryDelayMs: MAX_RETRY_AFTER_DELAY_MS, sessionId: this.options.sessionId ?? params.promptCache?.routingKey, cacheRetention: params.promptCache?.ttl === "5m" ? "short" : "none", ...(params.thinking?.type === "disabled" ? {} : { reasoning: params.effort ?? "medium" }),