File size: 7,645 Bytes
ed57015
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import { describe, it, expect } from 'vitest';
import { contentToString, flattenMessageContent, messageHasImage, normalizeOutboundContent, sanitizeResponse } from '../../lib/content.js';

describe('contentToString', () => {
  it('passes strings through', () => {
    expect(contentToString('hello')).toBe('hello');
    expect(contentToString('')).toBe('');
  });

  it('treats null and undefined as empty string', () => {
    expect(contentToString(null)).toBe('');
    expect(contentToString(undefined)).toBe('');
  });

  it('joins text blocks in OpenAI multimodal array envelope', () => {
    expect(contentToString([
      { type: 'text', text: 'hello ' },
      { type: 'text', text: 'world' },
    ])).toBe('hello world');
  });

  it('drops non-text blocks (image_url etc.) — text-only providers flatten this way', () => {
    expect(contentToString([
      { type: 'text', text: 'describe ' },
      { type: 'image_url', image_url: { url: 'https://example.com/x.png' } },
      { type: 'text', text: 'this' },
    ])).toBe('describe this');
  });

  it('extracts Gemini-part-style { text } blocks with no type field (#200)', () => {
    expect(contentToString([{ text: 'hi' }, { text: ' there' }])).toBe('hi there');
    // a typed NON-text block still gets dropped even when it carries text
    expect(contentToString([{ type: 'reasoning', text: 'inner monologue' }, { type: 'text', text: 'ok' }])).toBe('ok');
  });

  it('handles an array of bare strings (some clients send this)', () => {
    expect(contentToString(['foo', 'bar'])).toBe('foobar');
  });

  it('returns empty string for unrecognized types instead of throwing', () => {
    expect(contentToString(42 as unknown)).toBe('');
    expect(contentToString({ unknown: true } as unknown)).toBe('');
  });
});

describe('flattenMessageContent', () => {
  it('converts every message content to a string', () => {
    const out = flattenMessageContent([
      { role: 'user', content: 'plain' },
      { role: 'user', content: [{ type: 'text', text: 'array' }] },
      { role: 'assistant', content: null, tool_calls: [{ id: 'x', type: 'function', function: { name: 'f', arguments: '{}' } }] },
    ]);
    expect(out[0].content).toBe('plain');
    expect(out[1].content).toBe('array');
    expect(out[2].content).toBe('');
  });

  it('preserves other message fields (tool_calls, name, tool_call_id)', () => {
    const out = flattenMessageContent([
      { role: 'tool', content: 'result', tool_call_id: 'call-1', name: 'fn' },
    ]);
    expect(out[0]).toMatchObject({
      role: 'tool',
      content: 'result',
      tool_call_id: 'call-1',
      name: 'fn',
    });
  });
});

describe('messageHasImage', () => {
  it('detects image_url blocks (OpenAI vision envelope)', () => {
    expect(messageHasImage([
      { role: 'user', content: [
        { type: 'text', text: 'what is this?' },
        { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
      ] },
    ])).toBe(true);
  });

  it('detects a bare image block type', () => {
    expect(messageHasImage([
      { role: 'user', content: [{ type: 'image', source: 'x' } as any] },
    ])).toBe(true);
  });

  it('is false for string content and text-only arrays', () => {
    expect(messageHasImage([{ role: 'user', content: 'hello' }])).toBe(false);
    expect(messageHasImage([
      { role: 'user', content: [{ type: 'text', text: 'hello' }] },
    ])).toBe(false);
    expect(messageHasImage([{ role: 'assistant', content: null }])).toBe(false);
  });
});

describe('normalizeOutboundContent (#166)', () => {
  it('coerces array delta.content to a string on streaming chunks', () => {
    const chunk = { choices: [{ index: 0, delta: { content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] } }] };
    const out = normalizeOutboundContent(chunk);
    expect(out.choices[0].delta.content).toBe('hello');
  });

  it('coerces array message.content to a string on non-stream responses', () => {
    const result = { choices: [{ index: 0, message: { role: 'assistant', content: [{ type: 'text', text: 'got it' }] } }] };
    const out = normalizeOutboundContent(result);
    expect(out.choices[0].message.content).toBe('got it');
  });

  it('preserves tool_calls even when text content is array-shaped', () => {
    const chunk = { choices: [{ delta: { content: [{ type: 'text', text: '' }], tool_calls: [{ index: 0, id: 'c1', function: { name: 'f', arguments: '{}' } }] } }] };
    const out = normalizeOutboundContent(chunk);
    expect(out.choices[0].delta.content).toBe('');
    expect(out.choices[0].delta.tool_calls[0].id).toBe('c1');
  });

  it('leaves string content untouched', () => {
    const chunk = { choices: [{ delta: { content: 'already a string' } }] };
    expect(normalizeOutboundContent(chunk).choices[0].delta.content).toBe('already a string');
  });

  it('tolerates chunks with no choices array (usage/keepalive frames)', () => {
    expect(() => normalizeOutboundContent({ usage: { prompt_tokens: 1 } })).not.toThrow();
    expect(() => normalizeOutboundContent(null as unknown)).not.toThrow();
    expect(() => normalizeOutboundContent({} as unknown)).not.toThrow();
  });
});

describe('sanitizeResponse', () => {
  it('defaults a missing choice finish_reason to null', () => {
    const result = { choices: [{ index: 0, message: { role: 'assistant', content: 'hi' } }] };
    const out = sanitizeResponse(result) as any;
    expect(out.choices[0].finish_reason).toBeNull();
  });

  it('leaves an existing finish_reason untouched', () => {
    const result = { choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'hi' } }] };
    expect((sanitizeResponse(result) as any).choices[0].finish_reason).toBe('stop');
  });

  it('deletes a literal tool_calls: null on message and delta (the #200 mirror)', () => {
    const nonStream = { choices: [{ message: { role: 'assistant', content: 'hi', tool_calls: null } }] };
    const out1 = sanitizeResponse(nonStream) as any;
    expect('tool_calls' in out1.choices[0].message).toBe(false);

    const stream = { choices: [{ delta: { content: 'hi', tool_calls: null } }] };
    const out2 = sanitizeResponse(stream) as any;
    expect('tool_calls' in out2.choices[0].delta).toBe(false);
  });

  it('preserves a real tool_calls array', () => {
    const result = { choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] };
    const out = sanitizeResponse(result) as any;
    expect(out.choices[0].message.tool_calls).toHaveLength(1);
    expect(out.choices[0].message.tool_calls[0].id).toBe('c1');
  });

  it('coerces a non-string model to a string', () => {
    expect((sanitizeResponse({ model: 123, choices: [] }) as any).model).toBe('123');
    expect((sanitizeResponse({ model: 'gpt', choices: [] }) as any).model).toBe('gpt');
    expect((sanitizeResponse({ choices: [] }) as any).model).toBeUndefined();
  });

  it('does not touch content or reasoning_content', () => {
    const result = { choices: [{ message: { role: 'assistant', content: 'answer', reasoning_content: 'thinking' } }] };
    const out = sanitizeResponse(result) as any;
    expect(out.choices[0].message.content).toBe('answer');
    expect(out.choices[0].message.reasoning_content).toBe('thinking');
  });

  it('tolerates frames with no choices / non-objects', () => {
    expect(() => sanitizeResponse({ usage: { prompt_tokens: 1 } })).not.toThrow();
    expect(() => sanitizeResponse(null as unknown)).not.toThrow();
    expect(() => sanitizeResponse({} as unknown)).not.toThrow();
  });
});