From fefc0ce68647ec17cd62db4707b32ed00e40ec2d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 28 Jul 2026 16:31:51 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AF=20fix:=20Route=20the=20Label=20Cap?= =?UTF-8?q?=20Per=20Model=20Family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-twenty review: the 256-token label cap set maxTokens unconditionally, but GPT-5+ rejects max_tokens (the OpenAI builder routes its cap into modelKwargs.max_completion_tokens / max_output_tokens) and o-series models reject it with no stable kwargs alternative — every label on those models would have failed. The cap now mirrors the builder: modelKwargs for GPT-5+ (responses-API aware), no cap for o-series (title parity; the 200-char persistence bound still applies), maxOutputTokens for Google, maxTokens otherwise. Pinned in host.spec for both reasoning families. The round's other finding is the tenth instance of the documented edited+reconnect index limitation, answered on-thread. --- .../activityLabels/__tests__/host.spec.ts | 25 +++++++++++++++++ .../api/src/agents/activityLabels/host.ts | 28 +++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agents/activityLabels/__tests__/host.spec.ts b/packages/api/src/agents/activityLabels/__tests__/host.spec.ts index f6d5d35927..5de40f7428 100644 --- a/packages/api/src/agents/activityLabels/__tests__/host.spec.ts +++ b/packages/api/src/agents/activityLabels/__tests__/host.spec.ts @@ -197,6 +197,31 @@ describe('resolveActivityLabelModel model precedence', () => { expect((resolved.clientOptions as Record).maxTokens).toBe(256); }); + /** GPT-5+ rejects `max_tokens`: the label cap must ride in modelKwargs + * exactly as the OpenAI builder routes primary caps. */ + it('routes the label cap into modelKwargs for GPT-5-family models', async () => { + mockGetOptions.mockResolvedValueOnce({ + llmConfig: { model: 'gpt-5.2' }, + } as never); + const resolved = await resolve({ activityLabel: true, activityModel: 'gpt-5.2' }); + const clientOptions = resolved.clientOptions as Record; + expect(clientOptions.maxTokens).toBeUndefined(); + expect(clientOptions.modelKwargs).toEqual({ max_completion_tokens: 256 }); + }); + + /** o-series models reject `max_tokens` and get NO cap — title parity; + * the 200-char persistence bound still applies. */ + it('sets no cap at all for o-series reasoning models', async () => { + mockGetOptions.mockResolvedValueOnce({ + llmConfig: { model: 'o3-mini' }, + } as never); + const resolved = await resolve({ activityLabel: true, activityModel: 'o3-mini' }); + const clientOptions = resolved.clientOptions as Record; + expect(clientOptions.maxTokens).toBeUndefined(); + expect(clientOptions.modelKwargs).toBeUndefined(); + expect(clientOptions.maxOutputTokens).toBeUndefined(); + }); + /** The Anthropic carrier holds client CONSTRUCTION options — for * user-provided base URLs that includes the SSRF-safe fetch dispatcher — * so it must survive the strip even with no custom headers, and by the diff --git a/packages/api/src/agents/activityLabels/host.ts b/packages/api/src/agents/activityLabels/host.ts index 92bc5bd096..e4f937bfc7 100644 --- a/packages/api/src/agents/activityLabels/host.ts +++ b/packages/api/src/agents/activityLabels/host.ts @@ -334,17 +334,35 @@ export async function resolveActivityLabelModel({ const anthropicCarrier = rawOptions.clientOptions; const clientOptions = Object.fromEntries( Object.entries(rawOptions).filter(([key]) => !omitTitleOptions.has(key)), - ) as MaybeAzureConfig & { clientOptions?: { defaultHeaders?: unknown } }; + ) as MaybeAzureConfig & { + clientOptions?: { defaultHeaders?: unknown }; + modelKwargs?: Record; + }; if (anthropicCarrier != null && clientOptions.clientOptions == null) { clientOptions.clientOptions = anthropicCarrier; } /** Replace the stripped primary caps with a SMALL one — see - * {@link LABEL_MAX_OUTPUT_TOKENS}. Installed after the filter so the - * omit set cannot remove it; keyed per provider family (Google-style - * wrappers read `maxOutputTokens`, the rest `maxTokens`). */ + * {@link LABEL_MAX_OUTPUT_TOKENS} — routed with the SAME model/API + * conversion as the OpenAI builder (endpoints/openai/llm.ts): GPT-5+ + * rejects `max_tokens`, so its cap goes to `modelKwargs` + * (responses-API aware); o-series reasoning models get NO cap at all, + * matching the title path, since they reject `max_tokens` and have no + * stable kwargs cap across API surfaces (the 200-char persistence bound + * still applies); Google-family wrappers read `maxOutputTokens`. */ + const isGpt5Plus = model != null && /\bgpt-[5-9](?:\.\d+)?\b/i.test(model); + const isOSeries = model != null && /\bo[1-9](?:[-.]|\b)/i.test(model); if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { (clientOptions as { maxOutputTokens?: number }).maxOutputTokens = LABEL_MAX_OUTPUT_TOKENS; - } else { + } else if (isGpt5Plus) { + const paramName = + (rawOptions as { useResponsesApi?: boolean }).useResponsesApi === true + ? 'max_output_tokens' + : 'max_completion_tokens'; + clientOptions.modelKwargs = { + ...(clientOptions.modelKwargs ?? {}), + [paramName]: LABEL_MAX_OUTPUT_TOKENS, + }; + } else if (!isOSeries) { (clientOptions as { maxTokens?: number }).maxTokens = LABEL_MAX_OUTPUT_TOKENS; } if (options.configOptions) {