diff --git a/.changeset/summarize-skip-temperature-no-sampling.md b/.changeset/summarize-skip-temperature-no-sampling.md new file mode 100644 index 0000000000..279fd2618d --- /dev/null +++ b/.changeset/summarize-skip-temperature-no-sampling.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-anthropic': patch +--- + +`summarize()` no longer sends a default `temperature: 0.3` when the wrapped text adapter sets `supportsSamplingTemperature: false`. The Anthropic text adapter sets that flag for models that reject non-default sampling parameters, strips `temperature` / `top_p` / `top_k` on the wire for those models, and corrects `claude-opus-5-5` provider options so sampling knobs are not typed. diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 7538e2a2d3..ee5edfde1b 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -136,6 +136,8 @@ const result = await summarize({ console.log(result.summary); ``` +Some models reject a non-default `temperature`, `top_p`, or `top_k`. On those models, `summarize()` does not send its default `temperature` of `0.3`. + ## Files `anthropicFiles` uploads media to the Anthropic Files API, so a message can point to the file. See [Files API](../advanced/files-api). diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index faf733481f..f63f5c21bb 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -48,6 +48,7 @@ import { ANTHROPIC_MODEL_MID_CONVERSATION_CHANNELS, ANTHROPIC_MODEL_REASONING, ANTHROPIC_NO_FORCED_TOOL_MODELS, + anthropicModelSupportsSampling, getAnthropicDefaultMaxTokens, } from '../model-meta' import type { @@ -558,6 +559,11 @@ export class AnthropicTextAdapter< override readonly supportsFileSources = true override readonly inputModalities = ANTHROPIC_MODEL_INPUT_MODALITIES[this.model] + /** + * When `false`, `ChatStreamSummarizeAdapter` skips its default + * `temperature: 0.3` (models that reject non-default sampling). + */ + readonly supportsSamplingTemperature: boolean /** Set from `ANTHROPIC_MODEL_MID_CONVERSATION_CHANNELS` in the constructor. */ override readonly midConversationChannels: | MidConversationChannels @@ -581,6 +587,7 @@ export class AnthropicTextAdapter< constructor(config: AnthropicTextAdapterConfig, model: TModel) { super({}, model) + this.supportsSamplingTemperature = anthropicModelSupportsSampling(model) this.provider = config.provider ?? this.name this.allowEmptySignature = config.allowEmptySignature ?? false // On by default only on Anthropic's own API: a proxy, a gateway, Vertex, @@ -938,6 +945,8 @@ export class AnthropicTextAdapter< const validProviderOptions: Partial = {} if (modelOptions) { + const samplingKeys = ['top_k', 'temperature', 'top_p'] as const + const modelAllowsSampling = anthropicModelSupportsSampling(this.model) const validKeys: Array = [ 'cache_control', 'container', @@ -947,16 +956,20 @@ export class AnthropicTextAdapter< 'service_tier', 'stop_sequences', 'tool_choice', - 'top_k', - 'temperature', - 'top_p', + ...(modelAllowsSampling ? samplingKeys : []), ] // `max_tokens` is a legitimate public modelOptions field, but it is read // via a dedicated path (defaultMaxTokens below) rather than copied into // validProviderOptions. Exempt it from the dropped-key warning here so a // correct `modelOptions: { max_tokens }` call doesn't log a spurious // "dropped unknown key" error, while keeping it out of the copy loop. - const droppedKeyExemptSet = new Set([...validKeys, 'max_tokens']) + const droppedKeyExemptSet = new Set([ + ...validKeys, + 'max_tokens', + // Sampling keys are stripped (not "unknown") on no-sampling models — + // warn separately below so callers who cast around types get a signal. + ...(modelAllowsSampling ? [] : samplingKeys), + ]) const droppedKeys = Object.keys(modelOptions).filter( (key) => !droppedKeyExemptSet.has(key), ) @@ -977,6 +990,21 @@ export class AnthropicTextAdapter< }, ) } + if (!modelAllowsSampling) { + const strippedSampling = samplingKeys.filter( + (key) => key in modelOptions, + ) + if (strippedSampling.length > 0) { + options.logger.warn( + `anthropic.mapCommonOptionsToAnthropic omitted sampling key(s) for model=${this.model}: ${strippedSampling.join(', ')} (this model rejects non-default sampling parameters)`, + { + provider: 'anthropic', + model: this.model, + strippedSampling, + }, + ) + } + } for (const key of validKeys) { if (key in modelOptions) { const value = modelOptions[key] diff --git a/packages/ai-anthropic/src/index.ts b/packages/ai-anthropic/src/index.ts index a3efc190fb..729b13f84e 100644 --- a/packages/ai-anthropic/src/index.ts +++ b/packages/ai-anthropic/src/index.ts @@ -43,8 +43,10 @@ export type { } from './model-meta' export { ANTHROPIC_MODELS, + ANTHROPIC_MODELS_WITHOUT_SAMPLING, ANTHROPIC_VERTEX_CHAT_MODELS, ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS, + anthropicModelSupportsSampling, } from './model-meta' export type { AnthropicTextMetadata, diff --git a/packages/ai-anthropic/src/model-meta.ts b/packages/ai-anthropic/src/model-meta.ts index 4009f43a50..74a77bd67e 100644 --- a/packages/ai-anthropic/src/model-meta.ts +++ b/packages/ai-anthropic/src/model-meta.ts @@ -622,6 +622,9 @@ const CLAUDE_FABLE_5_1 = { AnthropicMaxTokensOptions > +// Claude Opus 5.5: adaptive-only thinking; non-default sampling parameters +// (`temperature`, `top_p`, `top_k`) return a 400. Provider options use +// `max_tokens` without the sampling knobs (Anthropic Opus 5.5 migration guide). const CLAUDE_OPUS_5_5 = { name: 'claude-opus-5-5', id: 'claude-opus-5-5', @@ -662,7 +665,7 @@ const CLAUDE_OPUS_5_5 = { AnthropicServiceTierOptions & AnthropicStopSequencesOptions & AnthropicToolChoiceOptions & - AnthropicSamplingOptions + AnthropicMaxTokensOptions > // Claude Sonnet 5.5: adaptive thinking is the default. `{type: 'disabled'}` @@ -777,6 +780,33 @@ export const ANTHROPIC_MODELS = [ CLAUDE_SONNET_5.id, ] as const +/** + * Models whose Messages API rejects non-default `temperature` / `top_p` / + * `top_k`. Keep in sync with `AnthropicChatModelProviderOptionsByName` + * entries that use `AnthropicMaxTokensOptions` instead of + * `AnthropicSamplingOptions`. Type tests in `model-meta.test.ts` enforce both + * directions so a new model id must pick one. + */ +export const ANTHROPIC_MODELS_WITHOUT_SAMPLING = [ + CLAUDE_OPUS_4_7.id, + CLAUDE_OPUS_4_8.id, + CLAUDE_FABLE_5.id, + CLAUDE_FABLE_5_1.id, + CLAUDE_SONNET_5.id, + CLAUDE_SONNET_5_5.id, + CLAUDE_OPUS_5_5.id, + CLAUDE_HAIKU_5_5.id, +] as const + +const ANTHROPIC_MODELS_WITHOUT_SAMPLING_SET: ReadonlySet = new Set( + ANTHROPIC_MODELS_WITHOUT_SAMPLING, +) + +/** `false` when the model rejects non-default sampling parameters. */ +export function anthropicModelSupportsSampling(model: string): boolean { + return !ANTHROPIC_MODELS_WITHOUT_SAMPLING_SET.has(model) +} + /** * Claude chat models on Vertex AI / Gemini Enterprise Agent Platform. * This list is the Google partner catalog, not the full Anthropic API catalog. @@ -1029,6 +1059,7 @@ export type AnthropicChatModelProviderOptionsByName = { AnthropicStopSequencesOptions & AnthropicToolChoiceOptions & AnthropicMaxTokensOptions + // Claude Opus 5.5: adaptive-only thinking, no sampling — see constant above. [CLAUDE_OPUS_5_5.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & @@ -1037,7 +1068,7 @@ export type AnthropicChatModelProviderOptionsByName = { AnthropicServiceTierOptions & AnthropicStopSequencesOptions & AnthropicToolChoiceOptions & - AnthropicSamplingOptions + AnthropicMaxTokensOptions // Claude Sonnet 5.5: adaptive-only thinking config, no sampling // parameters — see the CLAUDE_SONNET_5_5 constant above. [CLAUDE_SONNET_5_5.id]: AnthropicCacheControlOptions & diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index ea4892d052..eb3d367f42 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -621,6 +621,56 @@ describe('Anthropic adapter option mapping', () => { expect(payload.max_tokens).toBe(2048) }) + it('strips sampling keys on no-sampling models even when cast in (#1703)', async () => { + mocks.betaMessagesCreate.mockResolvedValueOnce(createTextStream('ok')) + + const adapter = new AnthropicTextAdapter( + { apiKey: 'test-key' }, + 'claude-sonnet-5-5', + ) + expect(adapter.supportsSamplingTemperature).toBe(false) + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + for await (const _ of chat({ + adapter, + threadId: 'thread-1', + messages: [{ role: 'user', content: 'Hi' }], + // Escape the per-model options type: temperature is rejected at the + // type level on this model, but the wire layer must still strip it. + modelOptions: { + temperature: 0.3, + top_p: 0.9, + top_k: 5, + max_tokens: 1024, + } as never, + debug: { logger, errors: true }, + })) { + // consume stream + } + + const [payload] = mocks.betaMessagesCreate.mock.calls[0]! + expect(payload.temperature).toBeUndefined() + expect(payload.top_p).toBeUndefined() + expect(payload.top_k).toBeUndefined() + expect(payload.max_tokens).toBe(1024) + + const stripWarning = logger.warn.mock.calls.find((call) => + String(call[0]).includes('omitted sampling key(s)'), + ) + expect(stripWarning).toBeDefined() + }) + + it('keeps supportsSamplingTemperature true on sampling models', () => { + const adapter = createAdapter('claude-sonnet-4-5') + expect(adapter.supportsSamplingTemperature).toBe(true) + }) + it('forwards context_management and attaches the required beta (issue #1074)', async () => { mocks.betaMessagesCreate.mockResolvedValueOnce(createTextStream('ok')) diff --git a/packages/ai-anthropic/tests/auth-parity.test.ts b/packages/ai-anthropic/tests/auth-parity.test.ts index f0b85897e0..00a3f1891d 100644 --- a/packages/ai-anthropic/tests/auth-parity.test.ts +++ b/packages/ai-anthropic/tests/auth-parity.test.ts @@ -394,7 +394,7 @@ describe('summarize factories', () => { expect(result.summary).toBe('Summary') }) - it('anthropicSummarize sends ANTHROPIC_API_KEY and keeps the request unchanged', async () => { + it('anthropicSummarize sends ANTHROPIC_API_KEY and omits temperature on no-sampling models', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'summarize-key') const { fetch, requests } = captureTransport() const result = await anthropicSummarize('claude-sonnet-5', { @@ -410,6 +410,8 @@ describe('summarize factories', () => { authorization: null, xApiKey: 'summarize-key', }) + // claude-sonnet-5 rejects non-default sampling — summarize must not + // inject temperature: 0.3 (#1703). expect(requests[0]!.body).toEqual({ model: 'claude-sonnet-5', max_tokens: 128, @@ -420,7 +422,6 @@ describe('summarize factories', () => { text: 'You are a professional summarizer. Provide a clear and concise summary. ', }, ], - temperature: 0.3, stream: true, }) expect(result.summary).toBe('Summary') diff --git a/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts b/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts index 4ca34bb17d..bc7d0498b1 100644 --- a/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts +++ b/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts @@ -260,6 +260,42 @@ describe('Anthropic per-model chat modelOptions gating', () => { }) }) + // Thinking and effort are `chat({ reasoning })`, not provider options. + describe('claude-opus-5-5 — max_tokens, no sampling', () => { + it('accepts max_tokens and the base options', () => { + chat({ + adapter: anthropicText('claude-opus-5-5'), + threadId: 'thread-1', + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { + service_tier: 'auto', + stop_sequences: ['STOP'], + tool_choice: { type: 'auto' }, + max_tokens: 2048, + }, + }) + }) + + it('rejects sampling parameters', () => { + chat({ + adapter: anthropicText('claude-opus-5-5'), + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { + // @ts-expect-error - 'temperature' is not available on claude-opus-5-5 + temperature: 0.5, + }, + }) + chat({ + adapter: anthropicText('claude-opus-5-5'), + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { + // @ts-expect-error - 'top_k' is not available on claude-opus-5-5 + top_k: 5, + }, + }) + }) + }) + describe('Model name type safety', () => { it('rejects unknown model names at the factory', () => { // @ts-expect-error - 'claude-fake-9000' is not a valid Anthropic chat model @@ -422,4 +458,19 @@ describe('Anthropic provider options shape assertions', () => { expectTypeOf().not.toHaveProperty('top_k') }) }) + + describe('claude-opus-5-5 — no sampling, thinking from chat({ reasoning })', () => { + type Options = AnthropicChatModelProviderOptionsByName['claude-opus-5-5'] + + it('has no thinking or output_config provider option', () => { + expectTypeOf().not.toHaveProperty('thinking') + expectTypeOf().not.toHaveProperty('output_config') + }) + it('has max_tokens but NOT temperature/top_p/top_k', () => { + expectTypeOf().toHaveProperty('max_tokens') + expectTypeOf().not.toHaveProperty('temperature') + expectTypeOf().not.toHaveProperty('top_p') + expectTypeOf().not.toHaveProperty('top_k') + }) + }) }) diff --git a/packages/ai-anthropic/tests/model-meta.test.ts b/packages/ai-anthropic/tests/model-meta.test.ts index 5e242a2827..88c51c0e9f 100644 --- a/packages/ai-anthropic/tests/model-meta.test.ts +++ b/packages/ai-anthropic/tests/model-meta.test.ts @@ -4,6 +4,8 @@ import { ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ANTHROPIC_MAX_NONSTREAMING_TOKENS, ANTHROPIC_MODELS, + ANTHROPIC_MODELS_WITHOUT_SAMPLING, + anthropicModelSupportsSampling, getAnthropicDefaultMaxTokens, } from '../src/model-meta' import type { @@ -163,6 +165,43 @@ describe('Anthropic Model Provider Options Type Assertions', () => { expectTypeOf<'claude-haiku-5-5'>().toExtend() }) }) + + describe('ANTHROPIC_MODELS_WITHOUT_SAMPLING stays in sync with provider options', () => { + type WithoutSampling = (typeof ANTHROPIC_MODELS_WITHOUT_SAMPLING)[number] + type WithSampling = Exclude< + (typeof ANTHROPIC_MODELS)[number], + WithoutSampling + > + + it('without-sampling models have max_tokens but not temperature/top_p/top_k', () => { + type Options = AnthropicChatModelProviderOptionsByName[WithoutSampling] + expectTypeOf().toHaveProperty('max_tokens') + expectTypeOf().not.toHaveProperty('temperature') + expectTypeOf().not.toHaveProperty('top_p') + expectTypeOf().not.toHaveProperty('top_k') + }) + + it('every other registered model exposes AnthropicSamplingOptions', () => { + type Options = AnthropicChatModelProviderOptionsByName[WithSampling] + expectTypeOf().toExtend() + }) + + it('without-sampling ids are a subset of ANTHROPIC_MODELS', () => { + const registered = new Set(ANTHROPIC_MODELS) + for (const id of ANTHROPIC_MODELS_WITHOUT_SAMPLING) { + expect(registered.has(id)).toBe(true) + } + }) + + it('anthropicModelSupportsSampling matches the const list', () => { + for (const id of ANTHROPIC_MODELS) { + const expected = !( + ANTHROPIC_MODELS_WITHOUT_SAMPLING as ReadonlyArray + ).includes(id) + expect(anthropicModelSupportsSampling(id)).toBe(expected) + } + }) + }) }) /** diff --git a/packages/ai/src/activities/summarize/chat-stream-summarize.ts b/packages/ai/src/activities/summarize/chat-stream-summarize.ts index 3e7f2fa967..1e8fc139bf 100644 --- a/packages/ai/src/activities/summarize/chat-stream-summarize.ts +++ b/packages/ai/src/activities/summarize/chat-stream-summarize.ts @@ -67,6 +67,12 @@ function throwRunError( export interface ChatStreamCapable { /** Native token-limit option for adapters whose provider name is user-defined. */ readonly maxTokensKey?: string + /** + * When `false`, summarize skips the default `temperature: 0.3`. + * `undefined` / `true` keep today's default. Use for models that reject + * non-default sampling parameters (e.g. Anthropic Claude Sonnet 5.5). + */ + readonly supportsSamplingTemperature?: boolean chatStream: (options: TextOptions) => AsyncIterable } @@ -372,7 +378,9 @@ export class ChatStreamSummarizeAdapter< let working: Record = { ...(options.modelOptions as Record | undefined), } - working = applyDefaultTemperature(this.name, 0.3, working) + if (this.textAdapter.supportsSamplingTemperature !== false) { + working = applyDefaultTemperature(this.name, 0.3, working) + } // `maxLength` must reach the wire under the provider-native token key (it // differs per provider, and no adapter reads a generic `maxTokens`). // Prefer the wrapped adapter's explicit key, then this wrapper's name, diff --git a/packages/ai/tests/summarize-max-length.test.ts b/packages/ai/tests/summarize-max-length.test.ts index c240e9de1d..a81bfb257a 100644 --- a/packages/ai/tests/summarize-max-length.test.ts +++ b/packages/ai/tests/summarize-max-length.test.ts @@ -148,6 +148,27 @@ describe('ChatStreamSummarizeAdapter — maxLength reaches the wrapped adapter u expect(opts?.['temperature']).toBe(0.9) }) + it('skips the temperature default when supportsSamplingTemperature is false', async () => { + const { textAdapter, lastModelOptions } = createRecordingTextAdapter() + const adapter = new ChatStreamSummarizeAdapter( + { ...textAdapter, supportsSamplingTemperature: false }, + 'claude-sonnet-5-5', + 'anthropic', + ) + + await adapter.summarize({ + model: 'claude-sonnet-5-5', + threadId: 'thread-1', + text: 'hi', + maxLength: 200, + logger, + }) + + const opts = lastModelOptions() + expect(opts?.['temperature']).toBeUndefined() + expect(opts?.['max_tokens']).toBe(200) + }) + it('unknown adapter name sets no token key and warns instead of silently dropping maxLength', async () => { const { textAdapter, lastModelOptions } = createRecordingTextAdapter() // Default name is 'chat-stream-summarize' — not a recognised provider.