feat: centralize model defaults; emit warnings when defaults are used… (#909)

Co-authored-by: Owen Kaplan <okapl@amazon.com>
This commit is contained in:
notowen333
2026-04-27 21:09:02 +00:00
committed by GitHub
co-authored by Owen Kaplan
parent ad8006f46b
commit c0992343e9
12 changed files with 225 additions and 30 deletions
+2
View File
@@ -58,6 +58,7 @@ sdk-typescript/
│ │ ├── logging/ # Structured logging
│ │ │ ├── __tests__/
│ │ │ ├── logger.ts
│ │ │ ├── warn-once.ts # Dedupe warnings by message content
│ │ │ ├── types.ts
│ │ │ └── index.ts
│ │ │
@@ -68,6 +69,7 @@ sdk-typescript/
│ │ │ ├── bedrock.ts # AWS Bedrock
│ │ │ ├── openai.ts # OpenAI
│ │ │ ├── vercel.ts # Vercel AI SDK
│ │ │ ├── defaults.ts # Centralized model defaults + warning messages
│ │ │ ├── model.ts # Base model interface
│ │ │ └── streaming.ts # Streaming event types
│ │ │
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest'
import type { Logger } from '../types.js'
import { warnOnce } from '../warn-once.js'
function createLogger(): Logger {
return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}
describe('warnOnce', () => {
it('emits a warning the first time a message is seen', () => {
const logger = createLogger()
warnOnce(logger, 'first-seen-msg')
expect(logger.warn).toHaveBeenCalledTimes(1)
expect(logger.warn).toHaveBeenCalledWith('first-seen-msg')
})
it('does not emit repeated warnings for the same message', () => {
const logger = createLogger()
warnOnce(logger, 'repeated-msg')
warnOnce(logger, 'repeated-msg')
warnOnce(logger, 'repeated-msg')
expect(logger.warn).toHaveBeenCalledTimes(1)
})
it('emits distinct messages independently', () => {
const logger = createLogger()
warnOnce(logger, 'distinct-alpha-msg')
warnOnce(logger, 'distinct-beta-msg')
warnOnce(logger, 'distinct-alpha-msg')
expect(logger.warn).toHaveBeenCalledTimes(2)
expect(logger.warn).toHaveBeenNthCalledWith(1, 'distinct-alpha-msg')
expect(logger.warn).toHaveBeenNthCalledWith(2, 'distinct-beta-msg')
})
})
+19
View File
@@ -0,0 +1,19 @@
import type { Logger } from './types.js'
const warned = new Set<string>()
/**
* Emits a warning log at most once per unique message per process.
*
* Subsequent calls with the same message are no-ops, which prevents
* repeated nudges (e.g. "using default modelId") from flooding logs
* when many instances are constructed.
*
* @param logger - Logger to emit the warning on
* @param msg - Warning message; also used as the dedupe key
*/
export function warnOnce(logger: Logger, msg: string): void {
if (warned.has(msg)) return
logger.warn(msg)
warned.add(msg)
}
@@ -13,6 +13,7 @@ import {
JsonBlock,
} from '../../types/messages.js'
import { ImageBlock, DocumentBlock, VideoBlock } from '../../types/media.js'
import { warnOnce } from '../../logging/warn-once.js'
/**
* Helper to create a mock Anthropic client with streaming support
@@ -39,10 +40,13 @@ vi.mock('@anthropic-ai/sdk', () => {
}
})
vi.mock('../../logging/warn-once.js', () => ({
warnOnce: vi.fn(),
}))
describe('AnthropicModel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
if (isNode) {
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-test-env')
}
@@ -101,12 +105,34 @@ describe('AnthropicModel', () => {
it('warns when maxTokens is not explicitly set', () => {
new AnthropicModel({ apiKey: 'sk-ant-test' })
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('using default maxTokens'))
expect(warnOnce).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default maxTokens')
)
})
it('does not warn when maxTokens is explicitly set', () => {
new AnthropicModel({ apiKey: 'sk-ant-test', maxTokens: 4096 })
expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining('using default maxTokens'))
expect(warnOnce).not.toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default maxTokens')
)
})
it('warns when modelId is not explicitly set', () => {
new AnthropicModel({ apiKey: 'sk-ant-test' })
expect(warnOnce).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('does not warn when modelId is explicitly set', () => {
new AnthropicModel({ apiKey: 'sk-ant-test', modelId: 'claude-3-opus-20240229' })
expect(warnOnce).not.toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
})
@@ -11,6 +11,7 @@ import { CitationsBlock } from '../../types/citations.js'
import type { StreamOptions } from '../model.js'
import { collectIterator } from '../../__fixtures__/model-test-helpers.js'
import { NOOP_TOOL_SPEC } from '../../tools/noop-tool.js'
import { warnOnce } from '../../logging/warn-once.js'
/**
* Helper function to mock BedrockRuntimeClient implementation with customizable config.
@@ -140,6 +141,10 @@ vi.mock('@aws-sdk/client-bedrock-runtime', async (importOriginal) => {
}
})
vi.mock('../../logging/warn-once.js', () => ({
warnOnce: vi.fn(),
}))
describe('BedrockModel', () => {
const BEDROCK_NOOP_TOOL_CONFIG = {
tools: [{ toolSpec: { ...NOOP_TOOL_SPEC, inputSchema: { json: NOOP_TOOL_SPEC.inputSchema } } }],
@@ -173,6 +178,22 @@ describe('BedrockModel', () => {
expect(config.modelId).toBeDefined()
})
it('warns when modelId is not explicitly set', () => {
new BedrockModel()
expect(warnOnce).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('does not warn when modelId is explicitly set', () => {
new BedrockModel({ modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0' })
expect(warnOnce).not.toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('uses provided model ID ', () => {
const customModelId = 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
const provider = new BedrockModel({ modelId: customModelId })
@@ -1327,8 +1348,8 @@ describe('BedrockModel', () => {
})
it('does not warn when array system prompt is provided without cacheConfig', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const provider = new BedrockModel()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const messages = [new Message({ role: 'user', content: [new TextBlock('Hello')] })]
const options: StreamOptions = {
systemPrompt: [
@@ -16,6 +16,11 @@ import type { ContentBlock } from '../../types/messages.js'
import { formatMessages, mapChunkToEvents } from '../google/adapters.js'
import type { GoogleStreamState } from '../google/types.js'
import { ImageBlock, DocumentBlock, VideoBlock } from '../../types/media.js'
import { warnOnce } from '../../logging/warn-once.js'
vi.mock('../../logging/warn-once.js', () => ({
warnOnce: vi.fn(),
}))
/**
* Helper to create a mock Gemini client with streaming support
@@ -84,6 +89,7 @@ function formatBlock(block: ContentBlock, role: 'user' | 'assistant' = 'user'):
describe('GoogleModel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('GEMINI_API_KEY', 'test-api-key')
})
@@ -108,6 +114,22 @@ describe('GoogleModel', () => {
expect(() => new GoogleModel({ client: mockClient })).not.toThrow()
})
it('warns when modelId is not explicitly set', () => {
new GoogleModel({ apiKey: 'test-key' })
expect(warnOnce).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('does not warn when modelId is explicitly set', () => {
new GoogleModel({ apiKey: 'test-key', modelId: 'gemini-2.5-flash' })
expect(warnOnce).not.toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
})
describe('updateConfig', () => {
@@ -7,6 +7,7 @@ import { collectIterator } from '../../__fixtures__/model-test-helpers.js'
import { Message, TextBlock, ToolUseBlock, ToolResultBlock, GuardContentBlock } from '../../types/messages.js'
import type { SystemContentBlock } from '../../types/messages.js'
import { ImageBlock, DocumentBlock, VideoBlock } from '../../types/media.js'
import { warnOnce } from '../../logging/warn-once.js'
/**
* Helper to create a mock OpenAI client with streaming support
@@ -31,6 +32,10 @@ vi.mock('openai', () => {
}
})
vi.mock('../../logging/warn-once.js', () => ({
warnOnce: vi.fn(),
}))
describe('OpenAIModel', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -81,6 +86,22 @@ describe('OpenAIModel', () => {
})
})
it('warns when modelId is not explicitly set', () => {
new OpenAIModel({ api: 'chat', apiKey: 'sk-test' })
expect(warnOnce).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('does not warn when modelId is explicitly set', () => {
new OpenAIModel({ api: 'chat', modelId: 'gpt-5.4', apiKey: 'sk-test' })
expect(warnOnce).not.toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
expect.stringContaining('using default modelId')
)
})
it('uses API key from constructor parameter', () => {
const apiKey = 'sk-explicit'
new OpenAIModel({ api: 'chat', modelId: 'gpt-5.4', apiKey })
+10 -8
View File
@@ -7,9 +7,9 @@ import { ContextWindowOverflowError, ModelThrottledError, normalizeError } from
import type { ImageBlock, DocumentBlock } from '../types/media.js'
import { encodeBase64 } from '../types/media.js'
import { logger } from '../logging/logger.js'
import { warnOnce } from '../logging/warn-once.js'
import { MODEL_DEFAULTS, defaultMaxTokensWarningMessage, defaultModelWarningMessage } from './defaults.js'
const DEFAULT_ANTHROPIC_MODEL_ID = 'claude-sonnet-4-6'
const DEFAULT_ANTHROPIC_MAX_TOKENS = 64_000
const CONTEXT_WINDOW_OVERFLOW_ERRORS = ['prompt is too long', 'max_tokens exceeded', 'input too long']
const TEXT_FILE_FORMATS = ['txt', 'md', 'markdown', 'csv', 'json', 'xml', 'html', 'yml', 'yaml', 'js', 'ts', 'py']
@@ -40,15 +40,17 @@ export class AnthropicModel extends Model<AnthropicModelConfig> {
const { apiKey, client, clientConfig, ...modelConfig } = options || {}
this._config = {
modelId: DEFAULT_ANTHROPIC_MODEL_ID,
maxTokens: DEFAULT_ANTHROPIC_MAX_TOKENS,
modelId: MODEL_DEFAULTS.anthropic.modelId,
maxTokens: MODEL_DEFAULTS.anthropic.maxTokens,
...modelConfig,
}
if (modelConfig.modelId === undefined) {
warnOnce(logger, defaultModelWarningMessage(MODEL_DEFAULTS.anthropic.modelId))
}
if (modelConfig.maxTokens === undefined) {
logger.warn(
`max_tokens=<${DEFAULT_ANTHROPIC_MAX_TOKENS}> | using default maxTokens, which is subject to change | set maxTokens explicitly to pin the value`
)
warnOnce(logger, defaultMaxTokensWarningMessage(MODEL_DEFAULTS.anthropic.maxTokens))
}
if (client) {
@@ -226,7 +228,7 @@ export class AnthropicModel extends Model<AnthropicModelConfig> {
const request: Anthropic.MessageStreamParams = {
model: this._config.modelId,
max_tokens: this._config.maxTokens ?? DEFAULT_ANTHROPIC_MAX_TOKENS,
max_tokens: this._config.maxTokens ?? MODEL_DEFAULTS.anthropic.maxTokens,
messages: this._formatMessages(messages),
stream: true,
}
+8 -9
View File
@@ -50,15 +50,10 @@ import type { JSONValue } from '../types/json.js'
import { ContextWindowOverflowError, ModelThrottledError, normalizeError } from '../errors.js'
import { ensureDefined } from '../types/validation.js'
import { logger } from '../logging/logger.js'
import { warnOnce } from '../logging/warn-once.js'
import { NOOP_TOOL_SPEC } from '../tools/noop-tool.js'
import { MODEL_DEFAULTS, defaultModelWarningMessage } from './defaults.js'
/**
* Default Bedrock model ID.
* Uses Claude Sonnet 4 with global inference profile for cross-region availability.
*/
const DEFAULT_BEDROCK_MODEL_ID = 'global.anthropic.claude-sonnet-4-6'
const DEFAULT_BEDROCK_REGION = 'us-west-2'
const DEFAULT_BEDROCK_REGION_SUPPORTS_FIP = false
/**
@@ -360,10 +355,14 @@ export class BedrockModel extends Model<BedrockModelConfig> {
// Initialize model config with default model ID if not provided
this._config = {
modelId: DEFAULT_BEDROCK_MODEL_ID,
modelId: MODEL_DEFAULTS.bedrock.modelId,
...modelConfig,
}
if (modelConfig.modelId === undefined) {
warnOnce(logger, defaultModelWarningMessage(MODEL_DEFAULTS.bedrock.modelId))
}
// Build user agent string (extend if provided, otherwise use SDK identifier)
const customUserAgent = clientConfig?.customUserAgent
? `${clientConfig.customUserAgent} strands-agents-ts-sdk`
@@ -1635,7 +1634,7 @@ function applyDefaultRegion(config: BedrockRuntimeClientResolvedConfig): void {
// Note: it was observed that the browser version of the BedrockClient
// uses a string instead of an error object - thus the normalizeError call
if (normalizeError(error).message === 'Region is missing') {
return DEFAULT_BEDROCK_REGION
return MODEL_DEFAULTS.bedrock.region
}
throw error
+43
View File
@@ -0,0 +1,43 @@
/**
* Default values for model providers.
*
* These defaults are subject to change between versions. Set values explicitly
* on model configurations to pin behavior across upgrades.
*/
export const MODEL_DEFAULTS = {
anthropic: {
modelId: 'claude-sonnet-4-6',
maxTokens: 64_000,
},
bedrock: {
modelId: 'global.anthropic.claude-sonnet-4-6',
region: 'us-west-2',
},
openai: {
modelId: 'gpt-5.4',
},
gemini: {
modelId: 'gemini-2.5-flash',
},
} as const
/**
* Builds a warning message for when the default model ID is used.
*
* @param defaultModelId - The default model ID being used
* @returns Formatted warning message string
*/
export function defaultModelWarningMessage(defaultModelId: string): string {
return `model_id=<${defaultModelId}> | using default modelId, which is subject to change | set modelId explicitly to pin the value`
}
/**
* Builds a warning message for when the default max tokens value is used.
*
* @param defaultMaxTokens - The default max tokens value being used
* @returns Formatted warning message 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`
}
+8 -6
View File
@@ -22,11 +22,9 @@ import type { GoogleModelConfig, GoogleModelOptions, GoogleStreamState } from '.
export type { GoogleModelConfig, GoogleModelOptions }
import { classifyGoogleError } from './errors.js'
import { formatMessages, mapChunkToEvents } from './adapters.js'
/**
* Default Gemini model ID.
*/
const DEFAULT_GEMINI_MODEL_ID = 'gemini-2.5-flash'
import { MODEL_DEFAULTS, defaultModelWarningMessage } from '../defaults.js'
import { warnOnce } from '../../logging/warn-once.js'
import { logger } from '../../logging/logger.js'
/**
* Google model provider implementation.
@@ -94,6 +92,10 @@ export class GoogleModel extends Model<GoogleModelConfig> {
this._config = modelConfig
if (modelConfig.modelId === undefined) {
warnOnce(logger, defaultModelWarningMessage(MODEL_DEFAULTS.gemini.modelId))
}
if (client) {
this._client = client
} else {
@@ -296,7 +298,7 @@ export class GoogleModel extends Model<GoogleModelConfig> {
}
return {
model: this._config.modelId ?? DEFAULT_GEMINI_MODEL_ID,
model: this._config.modelId ?? MODEL_DEFAULTS.gemini.modelId,
contents,
config,
}
+7 -3
View File
@@ -19,6 +19,8 @@ import type { ModelStreamEvent } from '../models/streaming.js'
import { ContextWindowOverflowError, ModelThrottledError } from '../errors.js'
import type { ChatCompletionContentPartText } from 'openai/resources/index.mjs'
import { logger } from '../logging/logger.js'
import { warnOnce } from '../logging/warn-once.js'
import { MODEL_DEFAULTS, defaultModelWarningMessage } from './defaults.js'
/**
* Supported OpenAI API types.
@@ -26,8 +28,6 @@ import { logger } from '../logging/logger.js'
*/
export type OpenAIApi = 'chat'
const DEFAULT_OPENAI_MODEL_ID = 'gpt-5.4'
/**
* Error message patterns that indicate context window overflow.
* Used to detect when input exceeds the model's context window.
@@ -263,6 +263,10 @@ export class OpenAIModel extends Model<OpenAIModelConfig> {
// Initialize model config
this._config = modelConfig
if (modelConfig.modelId === undefined) {
warnOnce(logger, defaultModelWarningMessage(MODEL_DEFAULTS.openai.modelId))
}
// Use provided client or create a new one
if (client) {
this._client = client
@@ -466,7 +470,7 @@ export class OpenAIModel extends Model<OpenAIModelConfig> {
): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming {
// Start with required fields
const request: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: this._config.modelId ?? DEFAULT_OPENAI_MODEL_ID,
model: this._config.modelId ?? MODEL_DEFAULTS.openai.modelId,
messages: [] as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
stream: true,
stream_options: { include_usage: true },