diff --git a/package.json b/package.json index 0770fd1..f8dc822 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/rosetta-ai", - "version": "1.11.4", + "version": "1.12.0", "description": "Unified TypeScript SDK for interacting with multiple AI providers (Anthropic, Google, Groq, OpenAI).", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/core/mapping/common.utils.ts b/src/core/mapping/common.utils.ts index 7903b4b..012234e 100644 --- a/src/core/mapping/common.utils.ts +++ b/src/core/mapping/common.utils.ts @@ -83,6 +83,8 @@ export function mapBaseParams( temperature?: number topP?: number maxTokens?: number + reasoningEffort?: GenerateParams['reasoningEffort'] + verbosity?: GenerateParams['verbosity'] stopSequences?: string[] } { const stopSequences = Array.isArray(params.stop) ? params.stop : params.stop ? [params.stop] : undefined @@ -102,12 +104,16 @@ export function mapBaseParams( temperature?: number topP?: number maxTokens?: number + reasoningEffort?: GenerateParams['reasoningEffort'] + verbosity?: GenerateParams['verbosity'] stopSequences?: string[] } = {} if (params.temperature !== undefined) result.temperature = params.temperature if (params.topP !== undefined) result.topP = params.topP if (effectiveMaxTokens !== undefined) result.maxTokens = effectiveMaxTokens + if (params.reasoningEffort !== undefined) result.reasoningEffort = params.reasoningEffort + if (params.verbosity !== undefined) result.verbosity = params.verbosity if (stopSequences !== undefined) result.stopSequences = stopSequences return result diff --git a/src/core/mapping/gpt5.support.ts b/src/core/mapping/gpt5.support.ts new file mode 100644 index 0000000..2f9fdeb --- /dev/null +++ b/src/core/mapping/gpt5.support.ts @@ -0,0 +1,164 @@ +import type { ReasoningEffort } from '../../types' + +export interface Gpt5Support { + chatCompletionsSupported: boolean + allowedReasoningEfforts: ReasoningEffort[] + defaultReasoningEffort?: ReasoningEffort + fixedReasoningEffort?: ReasoningEffort + supportsVerbosity: boolean + supportsSampling: 'never' | 'always' | 'only_with_reasoning_none' +} + +// Chat Completions-supported GPT-5 models verified for this implementation. +const GPT5_CHAT_COMPLETIONS_SUPPORT: Record = { + 'gpt-5': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['minimal', 'low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5-mini': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['minimal', 'low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5-nano': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['minimal', 'low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5.1': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['none', 'low', 'medium', 'high'], + defaultReasoningEffort: 'none', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5.2': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['none', 'low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'none', + supportsVerbosity: true, + supportsSampling: 'only_with_reasoning_none' + }, + 'gpt-5.4': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['none', 'low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'none', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5-chat-latest': { + chatCompletionsSupported: true, + allowedReasoningEfforts: [], + supportsVerbosity: true, + supportsSampling: 'always' + }, + 'gpt-5.1-chat-latest': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['medium'], + fixedReasoningEffort: 'medium', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5.2-chat-latest': { + chatCompletionsSupported: true, + allowedReasoningEfforts: ['medium'], + fixedReasoningEffort: 'medium', + supportsVerbosity: true, + supportsSampling: 'never' + } +} + +// Verified GPT-5 inventory that is treated as Responses-only / unsupported on Chat Completions here. +const GPT5_CHAT_COMPLETIONS_UNSUPPORTED: Record = { + 'gpt-5-codex': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5-pro': { + chatCompletionsSupported: false, + allowedReasoningEfforts: ['high'], + fixedReasoningEffort: 'high', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5-search-api': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.1-codex': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.1-codex-max': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.1-codex-mini': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.2-codex': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.2-pro': { + chatCompletionsSupported: false, + allowedReasoningEfforts: ['high'], + fixedReasoningEffort: 'high', + supportsVerbosity: true, + supportsSampling: 'never' + }, + 'gpt-5.3-chat-latest': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.3-codex': { + chatCompletionsSupported: false, + allowedReasoningEfforts: [], + supportsVerbosity: false, + supportsSampling: 'never' + }, + 'gpt-5.4-pro': { + chatCompletionsSupported: false, + allowedReasoningEfforts: ['high'], + fixedReasoningEffort: 'high', + supportsVerbosity: true, + supportsSampling: 'never' + } +} + +export const GPT5_SUPPORT_TABLE: Record = { + ...GPT5_CHAT_COMPLETIONS_SUPPORT, + ...GPT5_CHAT_COMPLETIONS_UNSUPPORTED +} + +const GPT5_SUPPORT_KEYS = Object.keys(GPT5_SUPPORT_TABLE).sort((a, b) => b.length - a.length) + +export function getGpt5Support(model: string): Gpt5Support | undefined { + const exact = GPT5_SUPPORT_TABLE[model] + if (exact) return exact + + const prefix = GPT5_SUPPORT_KEYS.find(key => model.startsWith(`${key}-`)) + return prefix ? GPT5_SUPPORT_TABLE[prefix] : undefined +} diff --git a/src/core/mapping/openai.mapper.ts b/src/core/mapping/openai.mapper.ts index 1893f9b..8bdb085 100644 --- a/src/core/mapping/openai.mapper.ts +++ b/src/core/mapping/openai.mapper.ts @@ -36,6 +36,7 @@ import { IProviderMapper } from './base.mapper' import { mapBaseParams, mapBaseToolChoice, mapToOpenAIResponseFormat } from './common.utils' import * as OpenAIEmbedMapper from './openai.embed.mapper' import * as OpenAIAudioMapper from './openai.audio.mapper' +import { getGpt5Support } from './gpt5.support' import { isGPT5Model, isThinkingModel, @@ -209,9 +210,65 @@ export class OpenAIMapper implements IProviderMapper { } else { delete basePayload.max_tokens } + if (isGPT5) { - if (basePayload.temperature != null && !isNaN(basePayload.temperature)) { - basePayload.temperature = 1 + const support = getGpt5Support(params.model!) + + if (support?.chatCompletionsSupported === false) { + throw new UnsupportedFeatureError(this.provider, `Chat Completions for model '${params.model!}'`) + } + + if (support) { + const requestedEffort = baseMappedParams.reasoningEffort + let effectiveEffort: GenerateParams['reasoningEffort'] | undefined + + if (support.fixedReasoningEffort) { + effectiveEffort = support.fixedReasoningEffort + } else if (support.allowedReasoningEfforts.length > 0) { + if ( + requestedEffort !== undefined && + support.allowedReasoningEfforts.includes(requestedEffort) + ) { + effectiveEffort = requestedEffort + } else { + effectiveEffort = support.defaultReasoningEffort + } + } + + if (effectiveEffort !== undefined) { + basePayload.reasoning_effort = effectiveEffort + } else { + delete basePayload.reasoning_effort + } + + if ( + support.supportsVerbosity && + baseMappedParams.verbosity !== undefined && + ['low', 'medium', 'high'].includes(baseMappedParams.verbosity) + ) { + basePayload.verbosity = baseMappedParams.verbosity + } else { + delete basePayload.verbosity + } + + const allowsSampling = + support.supportsSampling === 'always' || + (support.supportsSampling === 'only_with_reasoning_none' && effectiveEffort === 'none') + + if (!allowsSampling) { + delete basePayload.temperature + delete basePayload.top_p + } + + delete basePayload.max_tokens + } else { + // Conservative fallback for unknown future GPT-5-family models on Chat Completions: + // preserve token limit behavior but drop sampling and GPT-5-specific fields until modeled. + delete basePayload.temperature + delete basePayload.top_p + delete basePayload.reasoning_effort + delete basePayload.verbosity + delete basePayload.max_tokens } } diff --git a/src/core/rosetta-ai.ts b/src/core/rosetta-ai.ts index 3a5fdcf..2eb4ab7 100644 --- a/src/core/rosetta-ai.ts +++ b/src/core/rosetta-ai.ts @@ -727,6 +727,10 @@ export class RosettaAI { // --- Map Parameters --- const mappedParams = mapper.mapToProviderParams ? mapper.mapToProviderParams(effectiveParams) : effectiveParams + if (process.env.DEBUG === 'true' && providerKey === Provider.Anthropic) { + console.log(`[ROSETTA DEBUG] Mapped Anthropic stream request:\n${JSON.stringify(mappedParams, null, 2)}`) + } + // --- Execute --- if (isCustom && mapper.executeStream && customConfig) { // Custom Provider Execution Path diff --git a/src/types/params.types.ts b/src/types/params.types.ts index 42eecbf..df6d516 100644 --- a/src/types/params.types.ts +++ b/src/types/params.types.ts @@ -1,6 +1,9 @@ import { ProviderKey, RosettaMessage, RosettaTool, RosettaAudioData, GenerateParamsProviderState } from './common.types' import { ProviderOptions } from './config.types' +export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' +export type Verbosity = 'low' | 'medium' | 'high' + export type RosettaResponseFormat = | { type: 'text' } | { @@ -44,6 +47,10 @@ export interface GenerateParams { temperature?: number /** Nucleus sampling parameter: considers only tokens comprising the top `topP` probability mass. */ topP?: number + /** GPT-5 reasoning effort control for supported OpenAI Chat Completions models. */ + reasoningEffort?: ReasoningEffort + /** GPT-5 verbosity control for supported OpenAI Chat Completions models. */ + verbosity?: Verbosity /** Sequence(s) where the API will stop generating further tokens. */ stop?: string | string[] | null /** diff --git a/tests/unit/core/mapping/openai.mapper.spec.ts b/tests/unit/core/mapping/openai.mapper.spec.ts index c2229bd..f67b33e 100644 --- a/tests/unit/core/mapping/openai.mapper.spec.ts +++ b/tests/unit/core/mapping/openai.mapper.spec.ts @@ -411,6 +411,117 @@ describe('OpenAI Mapper', () => { expect(result.top_p).toBe(0.9) }) + it('[Medium] should drop sampling and default reasoning for gpt-5', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5', + messages: [{ role: 'user', content: 'Generate.' }], + temperature: 0.7, + topP: 0.9 + } + const result = mapper.mapToProviderParams(params) as any + expect(result.temperature).toBeUndefined() + expect(result.top_p).toBeUndefined() + expect(result.reasoning_effort).toBe('medium') + expect(result.max_completion_tokens).toBeUndefined() + }) + + it('[Medium] should preserve sampling for gpt-5.2 when reasoningEffort is none', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'Generate.' }], + temperature: 0.7, + topP: 0.9, + reasoningEffort: 'none', + verbosity: 'low', + maxTokens: 800 + } + const result = mapper.mapToProviderParams(params) as any + expect(result.temperature).toBe(0.7) + expect(result.top_p).toBe(0.9) + expect(result.reasoning_effort).toBe('none') + expect(result.verbosity).toBe('low') + expect(result.max_completion_tokens).toBe(800) + }) + + it('[Medium] should drop sampling for gpt-5.2 when reasoningEffort is high', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'Generate.' }], + temperature: 0.7, + topP: 0.9, + reasoningEffort: 'high' + } + const result = mapper.mapToProviderParams(params) as any + expect(result.temperature).toBeUndefined() + expect(result.top_p).toBeUndefined() + expect(result.reasoning_effort).toBe('high') + }) + + it('[Medium] should use family-aware lookup for dated gpt-5.2 models', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5.2-2025-12-11', + messages: [{ role: 'user', content: 'Generate.' }], + temperature: 0.7, + reasoningEffort: 'none', + verbosity: 'high' + } + const result = mapper.mapToProviderParams(params) as any + expect(result.temperature).toBe(0.7) + expect(result.reasoning_effort).toBe('none') + expect(result.verbosity).toBe('high') + }) + + it('[Medium] should map fixed reasoning for gpt-5.2-chat-latest', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5.2-chat-latest', + messages: [{ role: 'user', content: 'Generate.' }], + reasoningEffort: 'none', + temperature: 0.7, + verbosity: 'medium' + } + const result = mapper.mapToProviderParams(params) as any + expect(result.reasoning_effort).toBe('medium') + expect(result.temperature).toBeUndefined() + expect(result.top_p).toBeUndefined() + expect(result.verbosity).toBe('medium') + }) + + it('[Medium] should reject responses-only GPT-5 variants on chat completions', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5-pro', + messages: [{ role: 'user', content: 'Generate.' }] + } + expect(() => mapper.mapToProviderParams(params)).toThrow(UnsupportedFeatureError) + expect(() => mapper.mapToProviderParams(params)).toThrow( + "Provider 'openai' does not support the requested feature: Chat Completions for model 'gpt-5-pro'" + ) + }) + + it('[Medium] should conservatively normalize unknown future GPT-5 family models', () => { + const params: GenerateParams = { + ...baseParams, + model: 'gpt-5.9-experimental', + messages: [{ role: 'user', content: 'Generate.' }], + temperature: 0.7, + topP: 0.9, + reasoningEffort: 'high', + verbosity: 'high', + maxTokens: 321 + } + const result = mapper.mapToProviderParams(params) as any + expect(result.temperature).toBeUndefined() + expect(result.top_p).toBeUndefined() + expect(result.reasoning_effort).toBeUndefined() + expect(result.verbosity).toBeUndefined() + expect(result.max_completion_tokens).toBe(321) + }) + it('[Medium] should map toolChoice required and none', () => { const paramsRequired: GenerateParams = { ...baseParams, messages: [], toolChoice: 'required' } const paramsNone: GenerateParams = { ...baseParams, messages: [], toolChoice: 'none' }