From 7c00a77aa63aa94a613fb300ba7925ef48dade08 Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Fri, 1 May 2026 11:23:00 -0400 Subject: [PATCH] feat: auto-populate contextWindowLimit from model ID lookup tables (#954) --- .../src/models/__tests__/anthropic.test.ts | 55 +++++++ .../src/models/__tests__/bedrock.test.ts | 69 +++++++++ .../src/models/__tests__/defaults.test.ts | 39 +++++ .../src/models/__tests__/google.test.ts | 60 ++++++++ strands-ts/src/models/anthropic.ts | 10 +- strands-ts/src/models/bedrock.ts | 3 +- strands-ts/src/models/defaults.ts | 137 ++++++++++++++++++ strands-ts/src/models/google/model.ts | 4 +- strands-ts/src/models/model.ts | 22 +++ .../src/models/openai/__tests__/chat.test.ts | 53 +++++++ strands-ts/src/models/openai/model.ts | 4 +- 11 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 strands-ts/src/models/__tests__/defaults.test.ts diff --git a/strands-ts/src/models/__tests__/anthropic.test.ts b/strands-ts/src/models/__tests__/anthropic.test.ts index c42496755..3b8676615 100644 --- a/strands-ts/src/models/__tests__/anthropic.test.ts +++ b/strands-ts/src/models/__tests__/anthropic.test.ts @@ -136,6 +136,45 @@ describe('AnthropicModel', () => { expect.stringContaining('using default modelId') ) }) + + it('auto-populates contextWindowLimit from model ID lookup', () => { + const provider = new AnthropicModel({ apiKey: 'sk-test', modelId: 'claude-sonnet-4-20250514' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'claude-sonnet-4-20250514', + maxTokens: 64_000, + contextWindowLimit: 1_000_000, + }) + }) + + it('auto-populates contextWindowLimit for default model ID', () => { + const provider = new AnthropicModel({ apiKey: 'sk-test' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'claude-sonnet-4-6', + maxTokens: 64_000, + contextWindowLimit: 1_000_000, + }) + }) + + it('does not override explicit contextWindowLimit', () => { + const provider = new AnthropicModel({ + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-20250514', + contextWindowLimit: 100_000, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'claude-sonnet-4-20250514', + maxTokens: 64_000, + contextWindowLimit: 100_000, + }) + }) + + it('leaves contextWindowLimit undefined for unknown model IDs', () => { + const provider = new AnthropicModel({ apiKey: 'sk-test', modelId: 'unknown-model' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'unknown-model', + maxTokens: 64_000, + }) + }) }) describe('updateConfig', () => { @@ -147,6 +186,22 @@ describe('AnthropicModel', () => { maxTokens: 8192, }) }) + + it('re-resolves contextWindowLimit when modelId changes and it was auto-resolved', () => { + const provider = new AnthropicModel({ apiKey: 'sk-test' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_000_000) // claude-sonnet-4-6 default + + provider.updateConfig({ modelId: 'claude-sonnet-4-20250514' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_000_000) // claude-sonnet-4-20250514 value + }) + + it('preserves explicit contextWindowLimit when modelId changes', () => { + const provider = new AnthropicModel({ apiKey: 'sk-test', contextWindowLimit: 50_000 }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + + provider.updateConfig({ modelId: 'claude-sonnet-4-20250514' }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) // preserved + }) }) describe('stream event handling', () => { diff --git a/strands-ts/src/models/__tests__/bedrock.test.ts b/strands-ts/src/models/__tests__/bedrock.test.ts index 1c13ba4c7..9b2ec45a4 100644 --- a/strands-ts/src/models/__tests__/bedrock.test.ts +++ b/strands-ts/src/models/__tests__/bedrock.test.ts @@ -208,6 +208,7 @@ describe('BedrockModel', () => { const provider = new BedrockModel({ modelId: customModelId }) expect(provider.getConfig()).toStrictEqual({ modelId: customModelId, + contextWindowLimit: 200_000, }) }) @@ -292,6 +293,7 @@ describe('BedrockModel', () => { expect(config).toStrictEqual({ modelId: 'global.anthropic.claude-sonnet-4-6', temperature: 0.5, + contextWindowLimit: 1_000_000, }) }) @@ -305,6 +307,48 @@ describe('BedrockModel', () => { contextWindowLimit: 200_000, }) }) + + it('auto-populates contextWindowLimit from model ID lookup', () => { + const provider = new BedrockModel({ modelId: 'anthropic.claude-sonnet-4-20250514-v1:0' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'anthropic.claude-sonnet-4-20250514-v1:0', + contextWindowLimit: 1_000_000, + }) + }) + + it('auto-populates contextWindowLimit for cross-region model IDs', () => { + const provider = new BedrockModel({ modelId: 'us.anthropic.claude-sonnet-4-6' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'us.anthropic.claude-sonnet-4-6', + contextWindowLimit: 1_000_000, + }) + }) + + it('auto-populates contextWindowLimit for default model ID', () => { + const provider = new BedrockModel() + expect(provider.getConfig()).toStrictEqual({ + modelId: 'global.anthropic.claude-sonnet-4-6', + contextWindowLimit: 1_000_000, + }) + }) + + it('does not override explicit contextWindowLimit', () => { + const provider = new BedrockModel({ + modelId: 'anthropic.claude-sonnet-4-20250514-v1:0', + contextWindowLimit: 100_000, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'anthropic.claude-sonnet-4-20250514-v1:0', + contextWindowLimit: 100_000, + }) + }) + + it('leaves contextWindowLimit undefined for unknown model IDs', () => { + const provider = new BedrockModel({ modelId: 'unknown.model-v1:0' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'unknown.model-v1:0', + }) + }) }) describe('updateConfig', () => { @@ -315,6 +359,7 @@ describe('BedrockModel', () => { modelId: 'global.anthropic.claude-sonnet-4-6', temperature: 0.8, maxTokens: 2048, + contextWindowLimit: 1_000_000, }) }) @@ -332,6 +377,30 @@ describe('BedrockModel', () => { maxTokens: 1024, }) }) + + it('re-resolves contextWindowLimit when modelId changes and it was auto-resolved', () => { + const provider = new BedrockModel({ region: 'us-west-2' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_000_000) + + provider.updateConfig({ modelId: 'anthropic.claude-haiku-4-5-20251001-v1:0' }) + expect(provider.getConfig().contextWindowLimit).toBe(200_000) + }) + + it('clears contextWindowLimit when modelId changes to unknown model', () => { + const provider = new BedrockModel({ region: 'us-west-2' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_000_000) + + provider.updateConfig({ modelId: 'my-custom-finetuned-model' }) + expect(provider.getConfig().contextWindowLimit).toBeUndefined() + }) + + it('preserves explicit contextWindowLimit when modelId changes', () => { + const provider = new BedrockModel({ region: 'us-west-2', contextWindowLimit: 50_000 }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + + provider.updateConfig({ modelId: 'anthropic.claude-haiku-4-5-20251001-v1:0' }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + }) }) describe('getConfig', () => { diff --git a/strands-ts/src/models/__tests__/defaults.test.ts b/strands-ts/src/models/__tests__/defaults.test.ts new file mode 100644 index 000000000..6236a4788 --- /dev/null +++ b/strands-ts/src/models/__tests__/defaults.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { getContextWindowLimit } from '../defaults.js' + +describe('getContextWindowLimit', () => { + it('returns the context window limit for known model IDs across all providers', () => { + // Anthropic direct API + expect(getContextWindowLimit('claude-sonnet-4-6')).toBe(1_000_000) + expect(getContextWindowLimit('claude-opus-4-6')).toBe(1_000_000) + expect(getContextWindowLimit('claude-opus-4-5')).toBe(200_000) + expect(getContextWindowLimit('claude-haiku-4-5')).toBe(200_000) + // Bedrock Anthropic + expect(getContextWindowLimit('anthropic.claude-sonnet-4-6')).toBe(1_000_000) + // Bedrock Amazon Nova + expect(getContextWindowLimit('amazon.nova-pro-v1:0')).toBe(300_000) + expect(getContextWindowLimit('amazon.nova-micro-v1:0')).toBe(128_000) + // OpenAI + expect(getContextWindowLimit('gpt-5.4')).toBe(1_050_000) + expect(getContextWindowLimit('gpt-4o')).toBe(128_000) + expect(getContextWindowLimit('o3')).toBe(200_000) + expect(getContextWindowLimit('o4-mini')).toBe(200_000) + // Gemini + expect(getContextWindowLimit('gemini-2.5-flash')).toBe(1_048_576) + expect(getContextWindowLimit('gemini-2.5-pro')).toBe(1_048_576) + }) + + it('strips Bedrock cross-region prefix before lookup', () => { + expect(getContextWindowLimit('us.anthropic.claude-sonnet-4-6')).toBe(1_000_000) + expect(getContextWindowLimit('global.anthropic.claude-sonnet-4-6')).toBe(1_000_000) + }) + + it('does not strip unknown prefixes', () => { + expect(getContextWindowLimit('custom.gpt-5.4')).toBeUndefined() + }) + + it('returns undefined for unknown model IDs', () => { + expect(getContextWindowLimit('unknown-model-xyz')).toBeUndefined() + expect(getContextWindowLimit('us.unknown.model-v1:0')).toBeUndefined() + }) +}) diff --git a/strands-ts/src/models/__tests__/google.test.ts b/strands-ts/src/models/__tests__/google.test.ts index 7c872bba0..841bd8053 100644 --- a/strands-ts/src/models/__tests__/google.test.ts +++ b/strands-ts/src/models/__tests__/google.test.ts @@ -139,8 +139,33 @@ describe('GoogleModel', () => { expect(provider.getConfig()).toStrictEqual({ modelId: 'gemini-2.5-flash', params: { temperature: 0.5 }, + contextWindowLimit: 1_048_576, }) }) + + it('re-resolves contextWindowLimit when modelId changes and it was auto-resolved', () => { + const provider = new GoogleModel({ apiKey: 'test-key' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_048_576) + + provider.updateConfig({ modelId: 'gemini-2.0-flash' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_048_576) + }) + + it('clears contextWindowLimit when modelId changes to unknown model', () => { + const provider = new GoogleModel({ apiKey: 'test-key' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_048_576) + + provider.updateConfig({ modelId: 'my-custom-finetuned-model' }) + expect(provider.getConfig().contextWindowLimit).toBeUndefined() + }) + + it('preserves explicit contextWindowLimit when modelId changes', () => { + const provider = new GoogleModel({ apiKey: 'test-key', contextWindowLimit: 50_000 }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + + provider.updateConfig({ modelId: 'gemini-2.0-flash' }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + }) }) describe('getConfig', () => { @@ -153,6 +178,7 @@ describe('GoogleModel', () => { expect(provider.getConfig()).toStrictEqual({ modelId: 'gemini-2.5-flash', params: { maxOutputTokens: 1024, temperature: 0.7 }, + contextWindowLimit: 1_048_576, }) }) @@ -167,6 +193,40 @@ describe('GoogleModel', () => { contextWindowLimit: 1_048_576, }) }) + + it('auto-populates contextWindowLimit from model ID lookup', () => { + const provider = new GoogleModel({ apiKey: 'test-key', modelId: 'gemini-2.5-pro' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gemini-2.5-pro', + contextWindowLimit: 1_048_576, + }) + }) + + it('auto-populates contextWindowLimit for default model ID', () => { + const provider = new GoogleModel({ apiKey: 'test-key' }) + expect(provider.getConfig()).toStrictEqual({ + contextWindowLimit: 1_048_576, + }) + }) + + it('does not override explicit contextWindowLimit', () => { + const provider = new GoogleModel({ + apiKey: 'test-key', + modelId: 'gemini-2.5-flash', + contextWindowLimit: 500_000, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gemini-2.5-flash', + contextWindowLimit: 500_000, + }) + }) + + it('leaves contextWindowLimit undefined for unknown model IDs', () => { + const provider = new GoogleModel({ apiKey: 'test-key', modelId: 'unknown-model' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'unknown-model', + }) + }) }) describe('stream', () => { diff --git a/strands-ts/src/models/anthropic.ts b/strands-ts/src/models/anthropic.ts index e4965b40f..cfe772a2a 100644 --- a/strands-ts/src/models/anthropic.ts +++ b/strands-ts/src/models/anthropic.ts @@ -1,5 +1,11 @@ import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk' -import { Model, type BaseModelConfig, type CountTokensOptions, type StreamOptions } from '../models/model.js' +import { + Model, + type BaseModelConfig, + type CountTokensOptions, + type StreamOptions, + resolveConfigMetadata, +} from '../models/model.js' import type { Message, ContentBlock } from '../types/messages.js' import type { ModelStreamEvent } from '../models/streaming.js' import { createEmptyUsage } from '../models/streaming.js' @@ -81,7 +87,7 @@ export class AnthropicModel extends Model { } getConfig(): AnthropicModelConfig { - return this._config + return resolveConfigMetadata(this._config, this._config.modelId ?? MODEL_DEFAULTS.anthropic.modelId) } /** diff --git a/strands-ts/src/models/bedrock.ts b/strands-ts/src/models/bedrock.ts index e4e08e5c6..bda3abf05 100644 --- a/strands-ts/src/models/bedrock.ts +++ b/strands-ts/src/models/bedrock.ts @@ -48,6 +48,7 @@ import { type CountTokensOptions, Model, type StreamOptions, + resolveConfigMetadata, } from '../models/model.js' import type { ContentBlock, Message, StopReason, ToolUseBlock } from '../types/messages.js' import type { ImageSource, VideoSource, DocumentSource } from '../types/media.js' @@ -463,7 +464,7 @@ export class BedrockModel extends Model { * ``` */ getConfig(): BedrockModelConfig { - return this._config + return resolveConfigMetadata(this._config, this._config.modelId ?? MODEL_DEFAULTS.bedrock.modelId) } /** diff --git a/strands-ts/src/models/defaults.ts b/strands-ts/src/models/defaults.ts index 5e34fbbb6..7cab7dff9 100644 --- a/strands-ts/src/models/defaults.ts +++ b/strands-ts/src/models/defaults.ts @@ -41,3 +41,140 @@ export function defaultModelWarningMessage(defaultModelId: string): string { export function defaultMaxTokensWarningMessage(defaultMaxTokens: number): string { return `max_tokens=<${defaultMaxTokens}> | using default maxTokens, which is subject to change | set maxTokens explicitly to pin the value` } + +/** + * Context window limits (in tokens) for known model IDs. + * + * Best-effort lookup table — unknown models return `undefined` and callers + * fall back gracefully (e.g. proactive compression is disabled). + * Entries can be pruned when a model is no longer available from the provider. + * Users can always override with an explicit `contextWindowLimit` in their model config. + * + * Values sourced from provider documentation and + * https://github.com/BerriAI/litellm/blob/litellm_internal_staging/model_prices_and_context_window.json + * + * For Bedrock models with cross-region prefixes (e.g. `us.`, `eu.`, `global.`), + * {@link getContextWindowLimit} strips the prefix before lookup so only the base model ID is needed here. + */ +const CONTEXT_WINDOW_LIMITS: Record = { + // Anthropic (direct API) + 'claude-sonnet-4-6': 1_000_000, + 'claude-sonnet-4-20250514': 1_000_000, + 'claude-sonnet-4-5': 200_000, + 'claude-sonnet-4-5-20250929': 200_000, + 'claude-opus-4-6': 1_000_000, + 'claude-opus-4-6-20260205': 1_000_000, + 'claude-opus-4-7': 1_000_000, + 'claude-opus-4-7-20260416': 1_000_000, + 'claude-opus-4-5': 200_000, + 'claude-opus-4-5-20251101': 200_000, + 'claude-opus-4-20250514': 200_000, + 'claude-opus-4-1': 200_000, + 'claude-opus-4-1-20250805': 200_000, + 'claude-haiku-4-5': 200_000, + 'claude-haiku-4-5-20251001': 200_000, + 'claude-3-7-sonnet-20250219': 200_000, + 'claude-3-5-sonnet-20241022': 200_000, + 'claude-3-5-sonnet-20240620': 200_000, + 'claude-3-5-haiku-20241022': 200_000, + 'claude-3-opus-20240229': 200_000, + 'claude-3-haiku-20240307': 200_000, + + // Bedrock Anthropic (base model IDs — cross-region prefixes stripped by getContextWindowLimit) + 'anthropic.claude-sonnet-4-6': 1_000_000, + 'anthropic.claude-sonnet-4-20250514-v1:0': 1_000_000, + 'anthropic.claude-sonnet-4-5-20250929-v1:0': 200_000, + 'anthropic.claude-opus-4-6-v1': 1_000_000, + 'anthropic.claude-opus-4-7': 1_000_000, + 'anthropic.claude-opus-4-5-20251101-v1:0': 200_000, + 'anthropic.claude-opus-4-20250514-v1:0': 200_000, + 'anthropic.claude-opus-4-1-20250805-v1:0': 200_000, + 'anthropic.claude-haiku-4-5-20251001-v1:0': 200_000, + 'anthropic.claude-haiku-4-5@20251001': 200_000, + 'anthropic.claude-3-7-sonnet-20250219-v1:0': 200_000, + 'anthropic.claude-3-7-sonnet-20240620-v1:0': 200_000, + 'anthropic.claude-3-5-sonnet-20241022-v2:0': 200_000, + 'anthropic.claude-3-5-sonnet-20240620-v1:0': 200_000, + 'anthropic.claude-3-5-haiku-20241022-v1:0': 200_000, + 'anthropic.claude-3-opus-20240229-v1:0': 200_000, + 'anthropic.claude-3-haiku-20240307-v1:0': 200_000, + 'anthropic.claude-3-sonnet-20240229-v1:0': 200_000, + 'anthropic.claude-mythos-preview': 1_000_000, + + // Bedrock Amazon Nova + 'amazon.nova-pro-v1:0': 300_000, + 'amazon.nova-lite-v1:0': 300_000, + 'amazon.nova-micro-v1:0': 128_000, + 'amazon.nova-premier-v1:0': 1_000_000, + 'amazon.nova-2-lite-v1:0': 1_000_000, + 'amazon.nova-2-pro-preview-20251202-v1:0': 1_000_000, + + // OpenAI + 'gpt-5.5': 1_050_000, + 'gpt-5.5-pro': 1_050_000, + 'gpt-5.4': 1_050_000, + 'gpt-5.4-pro': 1_050_000, + 'gpt-5.4-mini': 272_000, + 'gpt-5.4-nano': 272_000, + 'gpt-5.2': 272_000, + 'gpt-5.2-pro': 272_000, + 'gpt-5.1': 272_000, + 'gpt-5': 272_000, + 'gpt-5-mini': 272_000, + 'gpt-5-nano': 272_000, + 'gpt-5-pro': 128_000, + 'gpt-4.1': 1_047_576, + 'gpt-4.1-mini': 1_047_576, + 'gpt-4.1-nano': 1_047_576, + 'gpt-4o': 128_000, + 'gpt-4o-mini': 128_000, + 'gpt-4-turbo': 128_000, + o3: 200_000, + 'o3-mini': 200_000, + 'o3-pro': 200_000, + 'o4-mini': 200_000, + o1: 200_000, + + // Google Gemini + 'gemini-2.5-flash': 1_048_576, + 'gemini-2.5-flash-lite': 1_048_576, + 'gemini-2.5-pro': 1_048_576, + 'gemini-2.0-flash': 1_048_576, + 'gemini-2.0-flash-lite': 1_048_576, + 'gemini-3-pro-preview': 1_048_576, + 'gemini-3-flash-preview': 1_048_576, + 'gemini-3.1-pro-preview': 1_048_576, + 'gemini-3.1-flash-lite-preview': 1_048_576, +} + +/** + * Known Bedrock cross-region routing prefixes. + * + * @see https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html + */ +const BEDROCK_REGION_PREFIXES = new Set(['us', 'eu', 'ap', 'global', 'apac', 'au', 'jp', 'us-gov']) + +/** + * Looks up the context window limit for a model ID. + * + * For Bedrock cross-region model IDs (e.g. `us.anthropic.claude-sonnet-4-6`), + * the region prefix is stripped before lookup. + * + * @param modelId - The model ID to look up + * @returns The context window limit in tokens, or undefined if not found + */ +export function getContextWindowLimit(modelId: string): number | undefined { + const direct = CONTEXT_WINDOW_LIMITS[modelId] + if (direct !== undefined) return direct + + // Strip known Bedrock cross-region prefixes + const dotIndex = modelId.indexOf('.') + if (dotIndex !== -1) { + const prefix = modelId.substring(0, dotIndex) + if (BEDROCK_REGION_PREFIXES.has(prefix)) { + return CONTEXT_WINDOW_LIMITS[modelId.substring(dotIndex + 1)] + } + } + + return undefined +} diff --git a/strands-ts/src/models/google/model.ts b/strands-ts/src/models/google/model.ts index 81f9d528f..004341fce 100644 --- a/strands-ts/src/models/google/model.ts +++ b/strands-ts/src/models/google/model.ts @@ -13,7 +13,7 @@ import { type GenerateContentConfig, type GenerateContentParameters, } from '@google/genai' -import { Model } from '../model.js' +import { Model, resolveConfigMetadata } from '../model.js' import type { CountTokensOptions, StreamOptions } from '../model.js' import type { Message } from '../../types/messages.js' import type { ModelStreamEvent } from '../streaming.js' @@ -144,7 +144,7 @@ export class GoogleModel extends Model { * ``` */ getConfig(): GoogleModelConfig { - return this._config + return resolveConfigMetadata(this._config, this._config.modelId ?? MODEL_DEFAULTS.gemini.modelId) } /** diff --git a/strands-ts/src/models/model.ts b/strands-ts/src/models/model.ts index f6e6cce89..892c46e97 100644 --- a/strands-ts/src/models/model.ts +++ b/strands-ts/src/models/model.ts @@ -26,6 +26,23 @@ import { import { MaxTokensError, ModelError, normalizeError } from '../errors.js' import type { Redaction } from '../hooks/events.js' import { logger } from '../logging/logger.js' +import { getContextWindowLimit } from './defaults.js' + +/** + * Resolves model metadata fields on a config object from built-in lookup tables + * when not explicitly set. Explicit values pass through unchanged. + * + * @internal + * @param config - The stored model config + * @param modelId - The model ID to look up + * @returns A new config with resolved metadata, or the original config if nothing to resolve + */ +export function resolveConfigMetadata(config: T, modelId: string): T { + if (config.contextWindowLimit !== undefined) return config + const limit = getContextWindowLimit(modelId) + if (limit === undefined) return config + return { ...config, contextWindowLimit: limit } +} class CitationAccumulator { citations: Citation[] = [] @@ -98,6 +115,11 @@ export interface BaseModelConfig { * Maximum context window size in tokens for the model. * * This value represents the total token capacity shared between input and output. + * When not provided, it is automatically resolved from a built-in lookup table + * based on the configured model ID. An explicit value always takes precedence. + * + * When `modelId` is changed via `updateConfig()`, this value is automatically + * re-resolved if it was initially auto-populated. Explicitly set values are preserved. */ contextWindowLimit?: number } diff --git a/strands-ts/src/models/openai/__tests__/chat.test.ts b/strands-ts/src/models/openai/__tests__/chat.test.ts index 20ba2b3b6..0601e36bd 100644 --- a/strands-ts/src/models/openai/__tests__/chat.test.ts +++ b/strands-ts/src/models/openai/__tests__/chat.test.ts @@ -229,6 +229,7 @@ describe('OpenAIModel', () => { modelId: 'gpt-5.4', temperature: 0.8, maxTokens: 2048, + contextWindowLimit: 1_050_000, }) }) @@ -247,6 +248,22 @@ describe('OpenAIModel', () => { maxTokens: 1024, }) }) + + it('re-resolves contextWindowLimit when modelId changes and it was auto-resolved', () => { + const provider = new OpenAIModel({ api: 'chat', apiKey: 'sk-test' }) + expect(provider.getConfig().contextWindowLimit).toBe(1_050_000) // gpt-5.4 default + + provider.updateConfig({ modelId: 'gpt-4o' }) + expect(provider.getConfig().contextWindowLimit).toBe(128_000) // gpt-4o value + }) + + it('preserves explicit contextWindowLimit when modelId changes', () => { + const provider = new OpenAIModel({ api: 'chat', apiKey: 'sk-test', contextWindowLimit: 50_000 }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) + + provider.updateConfig({ modelId: 'gpt-4o' }) + expect(provider.getConfig().contextWindowLimit).toBe(50_000) // preserved + }) }) describe('getConfig', () => { @@ -262,6 +279,7 @@ describe('OpenAIModel', () => { modelId: 'gpt-5.4', maxTokens: 1024, temperature: 0.7, + contextWindowLimit: 1_050_000, }) }) @@ -277,6 +295,41 @@ describe('OpenAIModel', () => { contextWindowLimit: 128_000, }) }) + + it('auto-populates contextWindowLimit from model ID lookup', () => { + const provider = new OpenAIModel({ api: 'chat', modelId: 'gpt-4o', apiKey: 'sk-test' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gpt-4o', + contextWindowLimit: 128_000, + }) + }) + + it('auto-populates contextWindowLimit for default model ID', () => { + const provider = new OpenAIModel({ api: 'chat', apiKey: 'sk-test' }) + expect(provider.getConfig()).toStrictEqual({ + contextWindowLimit: 1_050_000, + }) + }) + + it('does not override explicit contextWindowLimit', () => { + const provider = new OpenAIModel({ + api: 'chat', + modelId: 'gpt-4o', + apiKey: 'sk-test', + contextWindowLimit: 50_000, + }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'gpt-4o', + contextWindowLimit: 50_000, + }) + }) + + it('leaves contextWindowLimit undefined for unknown model IDs', () => { + const provider = new OpenAIModel({ api: 'chat', modelId: 'unknown-model', apiKey: 'sk-test' }) + expect(provider.getConfig()).toStrictEqual({ + modelId: 'unknown-model', + }) + }) }) describe('managed params warning', () => { diff --git a/strands-ts/src/models/openai/model.ts b/strands-ts/src/models/openai/model.ts index c7b105b27..d710b05ac 100644 --- a/strands-ts/src/models/openai/model.ts +++ b/strands-ts/src/models/openai/model.ts @@ -10,7 +10,7 @@ import OpenAI from 'openai' import type { ResponseStreamEvent } from 'openai/resources/responses/responses' -import { Model } from '../model.js' +import { Model, resolveConfigMetadata } from '../model.js' import type { StreamOptions } from '../model.js' import type { Message } from '../../types/messages.js' import type { ModelStreamEvent } from '../streaming.js' @@ -156,7 +156,7 @@ export class OpenAIModel extends Model { } getConfig(): OpenAIModelConfig { - return this._config + return resolveConfigMetadata(this._config, this._config.modelId ?? MODEL_DEFAULTS.openai.modelId) } async *stream(messages: Message[], options?: StreamOptions): AsyncIterable {