Skip to content

Commit 857a88e

Browse files
committed
fix: address CodeRabbit review comments
- responses-text: remove unsafe `any` cast for call_id, fix overly restrictive item.id guard on function_call handling - transcription: add ensureFileSupport() and decodeBase64() helpers for cross-environment safety (Node < 20, missing atob) - schema-converter.test: rename misleading oneOf test name - env.test: use unique generated keys for deterministic assertions
1 parent e3b8f5c commit 857a88e

File tree

5 files changed

+1958
-85
lines changed

5 files changed

+1958
-85
lines changed

packages/typescript/ai-groq/tests/schema-converter.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('makeGroqStructuredOutputCompatible', () => {
2929
expect(objectVariant.required).toBeUndefined()
3030
})
3131

32-
it('should remove empty required arrays inside oneOf variants', () => {
32+
it('should not have any empty required arrays in nested structures', () => {
3333
const schema = {
3434
type: 'object',
3535
properties: {

packages/typescript/ai-utils/tests/env.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe('getApiKeyFromEnv', () => {
1212
})
1313

1414
it('should throw if the env var is not set', () => {
15-
expect(() => getApiKeyFromEnv('NONEXISTENT_KEY')).toThrow('NONEXISTENT_KEY')
15+
const missingKey = `__AI_UTILS_TEST_MISSING_${Date.now()}__`
16+
expect(() => getApiKeyFromEnv(missingKey)).toThrow(missingKey)
1617
})
1718

1819
it('should throw if the env var is empty string', () => {
@@ -21,8 +22,7 @@ describe('getApiKeyFromEnv', () => {
2122
})
2223

2324
it('should include the env var name in the error message', () => {
24-
expect(() => getApiKeyFromEnv('MY_PROVIDER_API_KEY')).toThrow(
25-
'MY_PROVIDER_API_KEY',
26-
)
25+
const providerKey = `__AI_UTILS_TEST_PROVIDER_${Date.now()}__`
26+
expect(() => getApiKeyFromEnv(providerKey)).toThrow(providerKey)
2727
})
2828
})

packages/typescript/openai-base/src/adapters/responses-text.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -556,12 +556,17 @@ export class OpenAICompatibleResponsesTextAdapter<
556556
// handle output_item.added to capture function call metadata (name)
557557
if (chunk.type === 'response.output_item.added') {
558558
const item = chunk.item
559-
if (item.type === 'function_call' && item.id) {
560-
// Use call_id for tool call correlation (required for function_call_output)
561-
const callId = (item as any).call_id || item.id
559+
if (item.type === 'function_call') {
560+
// call_id is the required correlation ID for function_call_output.
561+
// id is the internal item ID used by delta/done events (item_id).
562+
// Use id when available (for the metadata map keyed by item_id),
563+
// falling back to call_id for providers that omit id.
564+
const itemId = item.id || item.call_id
565+
const callId = item.call_id || item.id || ''
566+
562567
// Store the function name for later use
563-
if (!toolCallMetadata.has(item.id)) {
564-
toolCallMetadata.set(item.id, {
568+
if (!toolCallMetadata.has(itemId)) {
569+
toolCallMetadata.set(itemId, {
565570
index: chunk.output_index,
566571
name: item.name || '',
567572
callId,
@@ -577,7 +582,7 @@ export class OpenAICompatibleResponsesTextAdapter<
577582
timestamp,
578583
index: chunk.output_index,
579584
}
580-
toolCallMetadata.get(item.id)!.started = true
585+
toolCallMetadata.get(itemId)!.started = true
581586
}
582587
}
583588

packages/typescript/openai-base/src/adapters/transcription.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -110,54 +110,82 @@ export class OpenAICompatibleTranscriptionAdapter<
110110

111111
// If Blob, convert to File
112112
if (typeof Blob !== 'undefined' && audio instanceof Blob) {
113+
this.ensureFileSupport()
113114
return new File([audio], 'audio.mp3', {
114115
type: audio.type || 'audio/mpeg',
115116
})
116117
}
117118

118119
// If ArrayBuffer, convert to File
119120
if (typeof ArrayBuffer !== 'undefined' && audio instanceof ArrayBuffer) {
121+
this.ensureFileSupport()
120122
return new File([audio], 'audio.mp3', { type: 'audio/mpeg' })
121123
}
122124

123125
// If base64 string, decode and convert to File
124126
if (typeof audio === 'string') {
127+
this.ensureFileSupport()
128+
125129
// Check if it's a data URL
126130
if (audio.startsWith('data:')) {
127131
const parts = audio.split(',')
128132
const header = parts[0]
129133
const base64Data = parts[1] || ''
130134
const mimeMatch = header?.match(/data:([^;]+)/)
131135
const mimeType = mimeMatch?.[1] || 'audio/mpeg'
132-
if (typeof atob !== 'function') {
133-
throw new Error(
134-
'atob is not available in this environment. Use a File, Blob, or ArrayBuffer input instead.',
135-
)
136-
}
137-
const binaryStr = atob(base64Data)
138-
const bytes = new Uint8Array(binaryStr.length)
139-
for (let i = 0; i < binaryStr.length; i++) {
140-
bytes[i] = binaryStr.charCodeAt(i)
141-
}
136+
const bytes = this.decodeBase64(base64Data)
142137
const extension = mimeType.split('/')[1] || 'mp3'
143138
return new File([bytes], `audio.${extension}`, { type: mimeType })
144139
}
145140

146141
// Assume raw base64
147-
if (typeof atob !== 'function') {
148-
throw new Error(
149-
'atob is not available in this environment. Use a File, Blob, or ArrayBuffer input instead.',
150-
)
151-
}
152-
const binaryStr = atob(audio)
142+
const bytes = this.decodeBase64(audio)
143+
return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' })
144+
}
145+
146+
throw new Error('Invalid audio input type')
147+
}
148+
149+
/**
150+
* Checks that the global `File` constructor is available.
151+
* Throws a descriptive error in environments that lack it (e.g. Node < 20).
152+
*/
153+
private ensureFileSupport(): void {
154+
if (typeof File === 'undefined') {
155+
throw new Error(
156+
'`File` is not available in this environment. ' +
157+
'Use Node.js 20 or newer, or pass a File object directly.',
158+
)
159+
}
160+
}
161+
162+
/**
163+
* Decodes a base64 string to an ArrayBuffer.
164+
* Uses `atob` when available, falling back to `Buffer.from` in Node.js.
165+
*/
166+
private decodeBase64(base64: string): ArrayBuffer {
167+
if (typeof atob === 'function') {
168+
const binaryStr = atob(base64)
153169
const bytes = new Uint8Array(binaryStr.length)
154170
for (let i = 0; i < binaryStr.length; i++) {
155171
bytes[i] = binaryStr.charCodeAt(i)
156172
}
157-
return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' })
173+
return bytes.buffer as ArrayBuffer
158174
}
159175

160-
throw new Error('Invalid audio input type')
176+
// Node.js fallback
177+
if (typeof Buffer !== 'undefined') {
178+
const buf = Buffer.from(base64, 'base64')
179+
return buf.buffer.slice(
180+
buf.byteOffset,
181+
buf.byteOffset + buf.byteLength,
182+
) as ArrayBuffer
183+
}
184+
185+
throw new Error(
186+
'Neither `atob` nor `Buffer` is available in this environment. ' +
187+
'Use a File, Blob, or ArrayBuffer input instead.',
188+
)
161189
}
162190

163191
/**

0 commit comments

Comments
 (0)