55// providers in 1.8.0 — see provider-registry.ts.
66
77import { describe , expect , it } from "vitest" ;
8- import { createOpenScreenChatModel , messageContentToText } from "./chat-model" ;
8+ import {
9+ ANTHROPIC_API_MAX_OUTPUT_TOKENS ,
10+ createOpenScreenChatModel ,
11+ messageContentToText ,
12+ messageContentToThinking ,
13+ } from "./chat-model" ;
914
1015/** ChatOpenAI keeps the `configuration` bag it was constructed with on
1116 * `clientConfig`; that is where the base URL and default headers land. */
@@ -35,6 +40,57 @@ describe("createOpenScreenChatModel — provider aliases", () => {
3540 } ) ;
3641} ) ;
3742
43+ describe ( "createOpenScreenChatModel — Anthropic-wire output budget" , ( ) => {
44+ // Regression for #181: ChatAnthropic's default maxTokens table only knows
45+ // Claude slugs (16k); anything else — MiniMax-M3 included — falls back to
46+ // 4096. With adaptive thinking on, a cold-start turn can spend that whole
47+ // budget on reasoning and truncate before any text block, surfacing as
48+ // "Empty response from model" on the first call only.
49+ function maxTokens ( model : unknown ) : number | undefined {
50+ return ( model as { maxTokens ?: number } ) . maxTokens ;
51+ }
52+
53+ for ( const provider of [ "minimax" , "minimax-token-plan" ] ) {
54+ it ( `sets an explicit maxTokens on the ${ provider } ChatAnthropic` , async ( ) => {
55+ const model = await createOpenScreenChatModel ( {
56+ provider,
57+ model : "MiniMax-M3" ,
58+ apiKey : "test-key" ,
59+ } ) ;
60+ expect ( model . constructor . name ) . toBe ( "ChatAnthropic" ) ;
61+ expect ( maxTokens ( model ) ) . toBe ( ANTHROPIC_API_MAX_OUTPUT_TOKENS ) ;
62+ } ) ;
63+ }
64+
65+ it ( "floors maxTokens for non-Claude models on the anthropic provider" , async ( ) => {
66+ const model = await createOpenScreenChatModel ( {
67+ provider : "anthropic" ,
68+ model : "some-self-hosted-model" ,
69+ apiKey : "sk-ant-test" ,
70+ baseUrl : "https://anthropic.example.internal" ,
71+ } ) ;
72+ expect ( maxTokens ( model ) ) . toBe ( ANTHROPIC_API_MAX_OUTPUT_TOKENS ) ;
73+ } ) ;
74+
75+ it ( "keeps LangChain's per-model default for known Claude slugs" , async ( ) => {
76+ // claude-3-haiku's hard output limit is 4096 — overriding it with 16k
77+ // would make the API reject every request for this model.
78+ const legacy = await createOpenScreenChatModel ( {
79+ provider : "anthropic" ,
80+ model : "claude-3-haiku-20240307" ,
81+ apiKey : "sk-ant-test" ,
82+ } ) ;
83+ expect ( maxTokens ( legacy ) ) . toBe ( 4096 ) ;
84+
85+ const current = await createOpenScreenChatModel ( {
86+ provider : "anthropic" ,
87+ model : "claude-haiku-4-5" ,
88+ apiKey : "sk-ant-test" ,
89+ } ) ;
90+ expect ( maxTokens ( current ) ) . toBe ( ANTHROPIC_API_MAX_OUTPUT_TOKENS ) ;
91+ } ) ;
92+ } ) ;
93+
3894describe ( "messageContentToText" , ( ) => {
3995 it ( "passes a plain string through" , ( ) => {
4096 expect ( messageContentToText ( "hello" ) ) . toBe ( "hello" ) ;
@@ -49,3 +105,43 @@ describe("messageContentToText", () => {
49105 expect ( messageContentToText ( 42 ) ) . toBe ( "" ) ;
50106 } ) ;
51107} ) ;
108+
109+ describe ( "messageContentToThinking" , ( ) => {
110+ // Anthropic/MiniMax thinking blocks land in AIMessageChunk content arrays
111+ // as `{type: "thinking", thinking: "..."}` parts (see @langchain/anthropic
112+ // message_outputs.js — `thinking_delta` SSE events). The extractor has to
113+ // pull them out so the chat panel can stream them separately; text parts
114+ // stay on the messageContentToText path.
115+ it ( "concatenates thinking parts in array order" , ( ) => {
116+ expect (
117+ messageContentToThinking ( [
118+ { type : "thinking" , thinking : "step one. " } ,
119+ { type : "text" , text : "should be ignored" } ,
120+ { type : "thinking" , thinking : "step two." } ,
121+ ] ) ,
122+ ) . toBe ( "step one. step two." ) ;
123+ } ) ;
124+
125+ it ( "ignores redacted_thinking blocks (encrypted reasoning the provider hides)" , ( ) => {
126+ // ChatAnthropic surfaces encrypted reasoning as parts of type
127+ // "redacted_thinking" — we don't have a string to display, so skip.
128+ expect (
129+ messageContentToThinking ( [
130+ { type : "thinking" , thinking : "visible. " } ,
131+ { type : "redacted_thinking" } ,
132+ { type : "thinking" , thinking : "more visible." } ,
133+ ] ) ,
134+ ) . toBe ( "visible. more visible." ) ;
135+ } ) ;
136+
137+ it ( "returns an empty string for a plain string or non-array input" , ( ) => {
138+ expect ( messageContentToThinking ( "not a list" ) ) . toBe ( "" ) ;
139+ expect ( messageContentToThinking ( null ) ) . toBe ( "" ) ;
140+ expect ( messageContentToThinking ( 42 ) ) . toBe ( "" ) ;
141+ } ) ;
142+
143+ it ( "returns an empty string when there are no thinking parts" , ( ) => {
144+ expect ( messageContentToThinking ( [ { type : "text" , text : "answer" } ] ) ) . toBe ( "" ) ;
145+ expect ( messageContentToThinking ( [ ] ) ) . toBe ( "" ) ;
146+ } ) ;
147+ } ) ;
0 commit comments