diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50caff538..449c4778f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,36 @@ jobs: - name: Run tests run: ./scripts/test + + ecosystem_tests: + name: ecosystem tests (v${{ matrix.node-version }}) + runs-on: ubuntu-latest + if: github.repository == 'openai/openai-node' + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ['18', '20'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '${{ matrix.node-version }}' + + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.39.0 + + - uses: oven-sh/setup-bun@v2 + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run ecosystem tests + run: | + yarn tsn ecosystem-tests/cli.ts --live --verbose --parallel --jobs=4 --retry=3 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b33fcdcea..721222a67 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.0.0-alpha.0" + ".": "5.0.0-beta.0" } diff --git a/README.md b/README.md index 2b85f3553..08226e928 100644 --- a/README.md +++ b/README.md @@ -287,8 +287,6 @@ main(); Like with `.stream()`, we provide a variety of [helpers and events](helpers.md#chat-events). -Note that `runFunctions` was previously available as well, but has been deprecated in favor of `runTools`. - Read more about various examples such as with integrating with [zod](helpers.md#integrate-with-zod), [next.js](helpers.md#integrate-with-nextjs), and [proxying a stream to the browser](helpers.md#proxy-streaming-to-a-browser). diff --git a/api.md b/api.md index 5b5c603b7..978bff992 100644 --- a/api.md +++ b/api.md @@ -347,7 +347,6 @@ Methods: Methods: -- client.beta.chat.completions.runFunctions(body, options?) -> ChatCompletionRunner | ChatCompletionStreamingRunner - client.beta.chat.completions.runTools(body, options?) -> ChatCompletionRunner | ChatCompletionStreamingRunner - client.beta.chat.completions.stream(body, options?) -> ChatCompletionStream diff --git a/ecosystem-tests/bun/bun.lockb b/ecosystem-tests/bun/bun.lockb index 529d137af..a01f1617e 100755 Binary files a/ecosystem-tests/bun/bun.lockb and b/ecosystem-tests/bun/bun.lockb differ diff --git a/ecosystem-tests/bun/openai.test.ts b/ecosystem-tests/bun/openai.test.ts index 979a4962f..f8facc521 100644 --- a/ecosystem-tests/bun/openai.test.ts +++ b/ecosystem-tests/bun/openai.test.ts @@ -43,6 +43,20 @@ test(`basic request works`, async function () { expectSimilar(completion.choices[0]?.message?.content, 'This is a test', 10); }); +test(`proxied request works`, async function () { + const client = new OpenAI({ + fetchOptions: { + proxy: process.env['ECOSYSTEM_TESTS_PROXY'], + }, + }); + + const completion = await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Say this is a test' }], + }); + expectSimilar(completion.choices[0]?.message?.content, 'This is a test', 10); +}); + test(`raw response`, async function () { const response = await client.chat.completions .create({ @@ -117,6 +131,14 @@ test('handles fs.ReadStream', async function () { expectSimilar(result.text, correctAnswer, 12); }); +test('handles Bun.File', async function () { + const result = await client.audio.transcriptions.create({ + file: Bun.file('sample1.mp3'), + model, + }); + expectSimilar(result.text, correctAnswer, 12); +}); + const fineTune = `{"prompt": "", "completion": ""}`; // @ts-ignore avoid DOM lib for testing purposes diff --git a/ecosystem-tests/bun/tsconfig.json b/ecosystem-tests/bun/tsconfig.json index 69a17ce99..f428d6b10 100644 --- a/ecosystem-tests/bun/tsconfig.json +++ b/ecosystem-tests/bun/tsconfig.json @@ -9,7 +9,7 @@ "allowImportingTsExtensions": true, "strict": true, "downlevelIteration": true, - "skipLibCheck": false, + "skipLibCheck": true, "jsx": "preserve", "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, diff --git a/ecosystem-tests/cli.ts b/ecosystem-tests/cli.ts index d4fa48b0d..9781c2b03 100644 --- a/ecosystem-tests/cli.ts +++ b/ecosystem-tests/cli.ts @@ -3,6 +3,8 @@ import execa from 'execa'; import yargs from 'yargs'; import assert from 'assert'; import path from 'path'; +import { createServer } from 'http'; +import { connect } from 'net'; const TAR_NAME = 'openai.tgz'; const PACK_FOLDER = '.pack'; @@ -109,6 +111,35 @@ const projectRunners = { let projectNames = Object.keys(projectRunners) as Array; const projectNamesSet = new Set(projectNames); +async function startProxy() { + const proxy = createServer((_req, res) => { + res.end(); + }); + + proxy.on('connect', (req, clientSocket, head) => { + console.log('got proxied connection'); + const serverSocket = connect(443, 'api.openai.com', () => { + clientSocket.write( + 'HTTP/1.1 200 Connection Established\r\n' + 'Proxy-agent: Node.js-Proxy\r\n' + '\r\n', + ); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + }); + + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)); + + const address = proxy.address(); + assert(address && typeof address !== 'string'); + process.env['ECOSYSTEM_TESTS_PROXY'] = 'http://127.0.0.1/' + address.port; + + return () => { + delete process.env['ECOSYSTEM_TESTS_PROXY']; + proxy.close(); + }; +} + function parseArgs() { return yargs(process.argv.slice(2)) .scriptName('ecosystem-tests') @@ -287,112 +318,118 @@ async function main() { await fileCache.cacheFiles(tmpFolderPath); } - if (jobs > 1) { - const queue = [...projectsToRun]; - const runningProjects = new Set(); - - const cursorLeft = '\x1B[G'; - const eraseLine = '\x1B[2K'; - - let progressDisplayed = false; - function clearProgress() { - if (progressDisplayed) { - process.stderr.write(cursorLeft + eraseLine); - progressDisplayed = false; + const stopProxy = await startProxy(); + try { + if (jobs > 1) { + const queue = [...projectsToRun]; + const runningProjects = new Set(); + + const cursorLeft = '\x1B[G'; + const eraseLine = '\x1B[2K'; + + let progressDisplayed = false; + function clearProgress() { + if (progressDisplayed) { + process.stderr.write(cursorLeft + eraseLine); + progressDisplayed = false; + } + } + const spinner = ['|', '/', '-', '\\']; + + function showProgress() { + clearProgress(); + progressDisplayed = true; + const spin = spinner[Math.floor(Date.now() / 500) % spinner.length]; + process.stderr.write( + `${spin} Running ${[...runningProjects].join(', ')}`.substring(0, process.stdout.columns - 3) + + '...', + ); } - } - const spinner = ['|', '/', '-', '\\']; - function showProgress() { - clearProgress(); - progressDisplayed = true; - const spin = spinner[Math.floor(Date.now() / 500) % spinner.length]; - process.stderr.write( - `${spin} Running ${[...runningProjects].join(', ')}`.substring(0, process.stdout.columns - 3) + '...', - ); - } + const progressInterval = setInterval(showProgress, process.stdout.isTTY ? 500 : 5000); + showProgress(); + + await Promise.all( + [...Array(jobs).keys()].map(async () => { + while (queue.length) { + const project = queue.shift(); + if (!project) { + break; + } - const progressInterval = setInterval(showProgress, process.stdout.isTTY ? 500 : 5000); - showProgress(); + // preserve interleaved ordering of writes to stdout/stderr + const chunks: { dest: 'stdout' | 'stderr'; data: string | Buffer }[] = []; + try { + runningProjects.add(project); + const child = execa( + 'yarn', + [ + 'tsn', + __filename, + project, + '--skip-pack', + '--noCleanup', + `--retry=${args.retry}`, + ...(args.live ? ['--live'] : []), + ...(args.verbose ? ['--verbose'] : []), + ...(args.deploy ? ['--deploy'] : []), + ...(args.fromNpm ? ['--from-npm'] : []), + ], + { stdio: 'pipe', encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 }, + ); + child.stdout?.on('data', (data) => chunks.push({ dest: 'stdout', data })); + child.stderr?.on('data', (data) => chunks.push({ dest: 'stderr', data })); + + await child; + } catch (error) { + failed.push(project); + } finally { + runningProjects.delete(project); + } - await Promise.all( - [...Array(jobs).keys()].map(async () => { - while (queue.length) { - const project = queue.shift(); - if (!project) { - break; - } + if (IS_CI) { + console.log(`::group::${failed.includes(project) ? '❌' : '✅'} ${project}`); + } - // preserve interleaved ordering of writes to stdout/stderr - const chunks: { dest: 'stdout' | 'stderr'; data: string | Buffer }[] = []; - try { - runningProjects.add(project); - const child = execa( - 'yarn', - [ - 'tsn', - __filename, - project, - '--skip-pack', - '--noCleanup', - `--retry=${args.retry}`, - ...(args.live ? ['--live'] : []), - ...(args.verbose ? ['--verbose'] : []), - ...(args.deploy ? ['--deploy'] : []), - ...(args.fromNpm ? ['--from-npm'] : []), - ], - { stdio: 'pipe', encoding: 'utf8', maxBuffer: 100 * 1024 * 1024 }, - ); - child.stdout?.on('data', (data) => chunks.push({ dest: 'stdout', data })); - child.stderr?.on('data', (data) => chunks.push({ dest: 'stderr', data })); - - await child; - } catch (error) { - failed.push(project); - } finally { - runningProjects.delete(project); + for (const { data } of chunks) { + process.stdout.write(data); + } + if (IS_CI) console.log('::endgroup::'); } + }), + ); - if (IS_CI) { - console.log(`::group::${failed.includes(project) ? '❌' : '✅'} ${project}`); - } + clearInterval(progressInterval); + clearProgress(); + } else { + for (const project of projectsToRun) { + const fn = projectRunners[project]; - for (const { data } of chunks) { - process.stdout.write(data); - } - if (IS_CI) console.log('::endgroup::'); - } - }), - ); - - clearInterval(progressInterval); - clearProgress(); - } else { - for (const project of projectsToRun) { - const fn = projectRunners[project]; - - await withChdir(path.join(rootDir, 'ecosystem-tests', project), async () => { - console.error('\n'); - console.error(banner(`▶️ ${project}`)); - console.error('\n'); - - try { - await withRetry(fn, project, state.retry, state.retryDelay); + await withChdir(path.join(rootDir, 'ecosystem-tests', project), async () => { + console.error('\n'); + console.error(banner(`▶️ ${project}`)); console.error('\n'); - console.error(`✅ ${project}`); - } catch (err) { - if (err && (err as any).shortMessage) { - console.error((err as any).shortMessage); - } else { - console.error(err); + + try { + await withRetry(fn, project, state.retry, state.retryDelay); + console.error('\n'); + console.error(`✅ ${project}`); + } catch (err) { + if (err && (err as any).shortMessage) { + console.error((err as any).shortMessage); + } else { + console.error(err); + } + console.error('\n'); + console.error(`❌ ${project}`); + failed.push(project); } console.error('\n'); - console.error(`❌ ${project}`); - failed.push(project); - } - console.error('\n'); - }); + }); + } } + } finally { + stopProxy(); } if (!args.noCleanup) { diff --git a/ecosystem-tests/cloudflare-worker/tsconfig.check.json b/ecosystem-tests/cloudflare-worker/tsconfig.check.json index 22d6f227b..d1b622447 100644 --- a/ecosystem-tests/cloudflare-worker/tsconfig.check.json +++ b/ecosystem-tests/cloudflare-worker/tsconfig.check.json @@ -1,8 +1,5 @@ { "extends": "./tsconfig.json", "include": ["src"], - "exclude": ["tests", "jest.config.cjs"], - "compilerOptions": { - "skipLibCheck": false - } + "exclude": ["tests", "jest.config.cjs"] } diff --git a/ecosystem-tests/node-ts-cjs-auto/tests/test.ts b/ecosystem-tests/node-ts-cjs-auto/tests/test.ts index 7782ae1d2..ea6913ef6 100644 --- a/ecosystem-tests/node-ts-cjs-auto/tests/test.ts +++ b/ecosystem-tests/node-ts-cjs-auto/tests/test.ts @@ -3,6 +3,7 @@ import { TranscriptionCreateParams } from 'openai/resources/audio/transcriptions import { File as FormDataFile, Blob as FormDataBlob } from 'formdata-node'; import * as fs from 'fs'; import { distance } from 'fastest-levenshtein'; +import { File } from 'node:buffer'; const url = 'https://audio-samples.github.io/samples/mp3/blizzard_biased/sample-1.mp3'; const filename = 'sample-1.mp3'; @@ -232,9 +233,9 @@ describe('toFile', () => { .then((x) => toFile(new FormDataFile([x], filename))); expect(file.name).toEqual(filename); - + const params: TranscriptionCreateParams = { file, model }; - + const result = await client.audio.transcriptions.create(params); expect(result.text).toBeSimilarTo(correctAnswer, 12); }); diff --git a/ecosystem-tests/node-ts-cjs/tests/test-jsdom.ts b/ecosystem-tests/node-ts-cjs/tests/test-jsdom.ts index e9c59f1c2..7910f62bd 100644 --- a/ecosystem-tests/node-ts-cjs/tests/test-jsdom.ts +++ b/ecosystem-tests/node-ts-cjs/tests/test-jsdom.ts @@ -80,7 +80,7 @@ it(`streaming works`, async function () { it.skip('handles builtinFile', async function () { const file = await fetch(url) - .then((x) => x.arrayBuffer()) + .then((x) => x.blob()) .then((x) => new File([x], filename)); const result = await client.audio.transcriptions.create({ file, model }); diff --git a/ecosystem-tests/node-ts-cjs/tests/test-node.ts b/ecosystem-tests/node-ts-cjs/tests/test-node.ts index 59ed83478..ebdc4e091 100644 --- a/ecosystem-tests/node-ts-cjs/tests/test-node.ts +++ b/ecosystem-tests/node-ts-cjs/tests/test-node.ts @@ -1,10 +1,11 @@ import OpenAI, { toFile } from 'openai'; import { TranscriptionCreateParams } from 'openai/resources/audio/transcriptions'; -import fetch from 'node-fetch'; +import * as undici from 'undici'; import { File as FormDataFile, Blob as FormDataBlob } from 'formdata-node'; import * as fs from 'fs'; import { distance } from 'fastest-levenshtein'; import { ChatCompletion } from 'openai/resources/chat/completions'; +import { File } from 'node:buffer'; const url = 'https://audio-samples.github.io/samples/mp3/blizzard_biased/sample-1.mp3'; const filename = 'sample-1.mp3'; @@ -181,9 +182,9 @@ describe('toFile', () => { .then((x) => toFile(new FormDataFile([x], filename))); expect(file.name).toEqual(filename); - + const params: TranscriptionCreateParams = { file, model }; - + const result = await client.audio.transcriptions.create(params); expect(result.text).toBeSimilarTo(correctAnswer, 12); }); diff --git a/ecosystem-tests/node-ts-cjs/tsconfig.json b/ecosystem-tests/node-ts-cjs/tsconfig.json index ef91fa3f0..a7b5e119f 100644 --- a/ecosystem-tests/node-ts-cjs/tsconfig.json +++ b/ecosystem-tests/node-ts-cjs/tsconfig.json @@ -9,8 +9,9 @@ /* Language and Environment */ "target": "ES2015", - "lib": ["ES2015", "DOM.AsyncIterable"], + "lib": ["ES2015"], "jsx": "react", + "types": ["node", "jest"], /* Modules */ "module": "commonjs", diff --git a/ecosystem-tests/node-ts-cjs/tsconfig.nodenext.json b/ecosystem-tests/node-ts-cjs/tsconfig.nodenext.json index a5c7de8cb..eb9e3ec20 100644 --- a/ecosystem-tests/node-ts-cjs/tsconfig.nodenext.json +++ b/ecosystem-tests/node-ts-cjs/tsconfig.nodenext.json @@ -9,8 +9,9 @@ /* Language and Environment */ "target": "ES2015", - "lib": ["ES2015", "DOM.AsyncIterable"], + "lib": ["ES2015"], "jsx": "react", + "types": ["node", "jest"], /* Modules */ "module": "NodeNext", diff --git a/ecosystem-tests/node-ts-esm-auto/tests/test.ts b/ecosystem-tests/node-ts-esm-auto/tests/test.ts index 5e9c8f961..855a925c5 100644 --- a/ecosystem-tests/node-ts-esm-auto/tests/test.ts +++ b/ecosystem-tests/node-ts-esm-auto/tests/test.ts @@ -4,6 +4,7 @@ import { File as FormDataFile, Blob as FormDataBlob } from 'formdata-node'; import * as fs from 'fs'; import { distance } from 'fastest-levenshtein'; import { ChatCompletion } from 'openai/resources/chat/completions'; +import { File } from 'node:buffer'; const url = 'https://audio-samples.github.io/samples/mp3/blizzard_biased/sample-1.mp3'; const filename = 'sample-1.mp3'; @@ -166,9 +167,9 @@ describe('toFile', () => { .then((x) => toFile(new FormDataFile([x], filename))); expect(file.name).toEqual(filename); - + const params: TranscriptionCreateParams = { file, model }; - + const result = await client.audio.transcriptions.create(params); expect(result.text).toBeSimilarTo(correctAnswer, 12); }); diff --git a/ecosystem-tests/node-ts4.5-jest28/tests/test.ts b/ecosystem-tests/node-ts4.5-jest28/tests/test.ts index 5e9c8f961..855a925c5 100644 --- a/ecosystem-tests/node-ts4.5-jest28/tests/test.ts +++ b/ecosystem-tests/node-ts4.5-jest28/tests/test.ts @@ -4,6 +4,7 @@ import { File as FormDataFile, Blob as FormDataBlob } from 'formdata-node'; import * as fs from 'fs'; import { distance } from 'fastest-levenshtein'; import { ChatCompletion } from 'openai/resources/chat/completions'; +import { File } from 'node:buffer'; const url = 'https://audio-samples.github.io/samples/mp3/blizzard_biased/sample-1.mp3'; const filename = 'sample-1.mp3'; @@ -166,9 +167,9 @@ describe('toFile', () => { .then((x) => toFile(new FormDataFile([x], filename))); expect(file.name).toEqual(filename); - + const params: TranscriptionCreateParams = { file, model }; - + const result = await client.audio.transcriptions.create(params); expect(result.text).toBeSimilarTo(correctAnswer, 12); }); diff --git a/ecosystem-tests/proxy.ts b/ecosystem-tests/proxy.ts new file mode 100644 index 000000000..ab05685a3 --- /dev/null +++ b/ecosystem-tests/proxy.ts @@ -0,0 +1,27 @@ +import { createServer } from 'http'; +import { connect } from 'net'; + +async function startProxy() { + const proxy = createServer((_req, res) => { + res.end(); + }); + + proxy.on('connect', (req, clientSocket, head) => { + const serverSocket = connect(443, 'api.openai.com', () => { + clientSocket.write( + 'HTTP/1.1 200 Connection Established\r\n' + 'Proxy-agent: Node.js-Proxy\r\n' + '\r\n', + ); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + }); + + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)); + + console.log(proxy.address()!.toString()) + + return () => { + proxy.close() + } +} diff --git a/examples/stream-to-client-browser.ts b/examples/stream-to-client-browser.ts index 4430ccc68..4d9697505 100755 --- a/examples/stream-to-client-browser.ts +++ b/examples/stream-to-client-browser.ts @@ -5,10 +5,8 @@ * for easy demo purposes, but simulating use in the browser. * * To run it in a browser application, copy/paste it into a frontend application, - * remove the 'node-fetch' import, and replace `process.stdout.write` with - * a console.log or UI display. + * and replace `process.stdout.write` with a console.log or UI display. */ -import fetch from 'node-fetch'; import { ChatCompletionStream } from 'openai/lib/ChatCompletionStream'; fetch('http://github.com/service/http://localhost:3000/', { diff --git a/helpers.md b/helpers.md index 16bc1f277..f66ebb39c 100644 --- a/helpers.md +++ b/helpers.md @@ -353,8 +353,6 @@ chat completion request, not for the entire call run. See an example of automated function calls in action in [`examples/function-call-helpers.ts`](examples/function-call-helpers.ts). -Note, `runFunctions` was also previously available, but has been deprecated in favor of `runTools`. - ### Chat Events #### `.on('connect', () => …)` diff --git a/package.json b/package.json index 93749e2f8..15e5c4143 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openai", - "version": "5.0.0-alpha.0", + "version": "5.0.0-beta.0", "description": "The official TypeScript library for the OpenAI API", "author": "OpenAI ", "types": "dist/index.d.ts", @@ -51,6 +51,9 @@ "ws": "^8.18.0", "zod": "^3.23.8" }, + "resolutions": { + "synckit": "0.8.8" + }, "imports": { "openai": ".", "openai/*": "./src/*" diff --git a/scripts/build b/scripts/build index aadf87be6..dd2c9dd57 100755 --- a/scripts/build +++ b/scripts/build @@ -40,8 +40,8 @@ cp dist/index.d.ts dist/index.d.mts cp tsconfig.dist-src.json dist/src/tsconfig.json cp src/internal/shim-types.d.ts dist/internal/shim-types.d.ts cp src/internal/shim-types.d.ts dist/internal/shim-types.d.mts -mkdir -p dist/internal/polyfill -cp src/internal/polyfill/*.{mjs,js,d.ts} dist/internal/polyfill +mkdir -p dist/internal/shims +cp src/internal/shims/*.{mjs,js,d.ts} dist/internal/shims node scripts/utils/postprocess-files.cjs diff --git a/src/client.ts b/src/client.ts index 478729321..9154dc496 100644 --- a/src/client.ts +++ b/src/client.ts @@ -641,7 +641,9 @@ export class OpenAI { const timeout = setTimeout(() => controller.abort(), ms); - const isReadableBody = Shims.isReadableLike(options.body); + const isReadableBody = + ((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body); const fetchOptions: RequestInit = { signal: controller.signal as any, diff --git a/src/internal/polyfill/file.node.d.ts b/src/internal/polyfill/file.node.d.ts deleted file mode 100644 index b2a59bfd5..000000000 --- a/src/internal/polyfill/file.node.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -export {}; diff --git a/src/internal/polyfill/file.node.js b/src/internal/polyfill/file.node.js deleted file mode 100644 index eba997e1d..000000000 --- a/src/internal/polyfill/file.node.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -if (typeof require !== 'undefined') { - if (!globalThis.File) { - try { - // Use [require][0](...) and not require(...) so bundlers don't try to bundle the - // buffer module. - globalThis.File = [require][0]('node:buffer').File; - } catch (e) {} - } -} diff --git a/src/internal/polyfill/file.node.mjs b/src/internal/polyfill/file.node.mjs deleted file mode 100644 index 520dcb84c..000000000 --- a/src/internal/polyfill/file.node.mjs +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file polyfills the global `File` object for you if it's not already defined - * when running on Node.js - * - * This is only needed on Node.js v18 & v19. Newer versions already define `File` - * as a global. - */ - -import './file.node.js'; diff --git a/src/internal/shims.ts b/src/internal/shims.ts index e084d8925..b1196d141 100644 --- a/src/internal/shims.ts +++ b/src/internal/shims.ts @@ -20,62 +20,6 @@ export function getDefaultFetch(): Fetch { ); } -/** - * A minimal copy of the NodeJS `stream.Readable` class so that we can - * accept the NodeJS types in certain places, e.g. file uploads - * - * https://nodejs.org/api/stream.html#class-streamreadable - */ -export interface ReadableLike { - readable: boolean; - readonly readableEnded: boolean; - readonly readableFlowing: boolean | null; - readonly readableHighWaterMark: number; - readonly readableLength: number; - readonly readableObjectMode: boolean; - destroyed: boolean; - read(size?: number): any; - pause(): this; - resume(): this; - isPaused(): boolean; - destroy(error?: Error): this; - [Symbol.asyncIterator](): AsyncIterableIterator; -} - -/** - * Determines if the given value looks like a NodeJS `stream.Readable` - * object and that it is readable, i.e. has not been consumed. - * - * https://nodejs.org/api/stream.html#class-streamreadable - */ -export function isReadableLike(value: any) { - // We declare our own class of Readable here, so it's not feasible to - // do an 'instanceof' check. Instead, check for Readable-like properties. - return !!value && value.readable === true && typeof value.read === 'function'; -} - -/** - * A minimal copy of the NodeJS `fs.ReadStream` class for usage within file uploads. - * - * https://nodejs.org/api/fs.html#class-fsreadstream - */ -export interface FsReadStreamLike extends ReadableLike { - path: {}; // real type is string | Buffer but we can't reference `Buffer` here -} - -/** - * Determines if the given value looks like a NodeJS `fs.ReadStream` - * object. - * - * This just checks if the object matches our `Readable` interface - * and defines a `path` property, there may be false positives. - * - * https://nodejs.org/api/fs.html#class-fsreadstream - */ -export function isFsReadStreamLike(value: any): value is FsReadStreamLike { - return isReadableLike(value) && 'path' in value; -} - type ReadableStreamArgs = ConstructorParameters; export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream { diff --git a/src/internal/polyfill/crypto.node.d.ts b/src/internal/shims/crypto.node.d.ts similarity index 100% rename from src/internal/polyfill/crypto.node.d.ts rename to src/internal/shims/crypto.node.d.ts diff --git a/src/internal/polyfill/crypto.node.js b/src/internal/shims/crypto.node.js similarity index 100% rename from src/internal/polyfill/crypto.node.js rename to src/internal/shims/crypto.node.js diff --git a/src/internal/polyfill/crypto.node.mjs b/src/internal/shims/crypto.node.mjs similarity index 100% rename from src/internal/polyfill/crypto.node.mjs rename to src/internal/shims/crypto.node.mjs diff --git a/src/internal/shims/file.node.d.ts b/src/internal/shims/file.node.d.ts new file mode 100644 index 000000000..9dc6b2fcc --- /dev/null +++ b/src/internal/shims/file.node.d.ts @@ -0,0 +1,20 @@ +// The infer is to make TS show it as a nice union type, +// instead of literally `ConstructorParameters[0]` +type FallbackBlobSource = ConstructorParameters[0] extends infer T ? T : never; +/** + * A [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) provides information about files. + */ +declare class FallbackFile extends Blob { + constructor(sources: FallbackBlobSource, fileName: string, options?: any); + /** + * The name of the `File`. + */ + readonly name: string; + /** + * The last modified date of the `File`. + */ + readonly lastModified: number; +} +export type File = InstanceType; +export const File: typeof globalThis extends { File: infer fileConstructor } ? fileConstructor +: typeof FallbackFile; diff --git a/src/internal/shims/file.node.js b/src/internal/shims/file.node.js new file mode 100644 index 000000000..3f8c2ed68 --- /dev/null +++ b/src/internal/shims/file.node.js @@ -0,0 +1,11 @@ +if (typeof require !== 'undefined') { + if (globalThis.File) { + exports.File = globalThis.File; + } else { + try { + // Use [require][0](...) and not require(...) so bundlers don't try to bundle the + // buffer module. + exports.File = [require][0]('node:buffer').File; + } catch (e) {} + } +} diff --git a/src/internal/shims/file.node.mjs b/src/internal/shims/file.node.mjs new file mode 100644 index 000000000..1f103f5d3 --- /dev/null +++ b/src/internal/shims/file.node.mjs @@ -0,0 +1,2 @@ +import * as mod from './file.node.js'; +export const File = globalThis.File || mod.File; diff --git a/src/internal/to-file.ts b/src/internal/to-file.ts new file mode 100644 index 000000000..69b76d3a6 --- /dev/null +++ b/src/internal/to-file.ts @@ -0,0 +1,152 @@ +import { File } from './shims/file.node.js'; +import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads'; +import type { FilePropertyBag } from './builtin-types'; + +type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.size === 'number' && + typeof value.type === 'string' && + typeof value.text === 'function' && + typeof value.slice === 'function' && + typeof value.arrayBuffer === 'function'; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.lastModified === 'number' && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === 'object' && + typeof value.url === 'string' && + typeof value.blob === 'function'; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude + | AsyncIterable; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise { + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (File && value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); + if (typeof type === 'string') { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes(value: BlobLikePart | AsyncIterable): Promise> { + let parts: Array = []; + if ( + typeof value === 'string' || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : '' + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== 'object' || value === null) return ''; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; +} diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index d89af49b1..2c286497c 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -1,11 +1,16 @@ import { type RequestOptions } from './request-options'; import type { FilePropertyBag, Fetch } from './builtin-types'; -import { isFsReadStreamLike, type FsReadStreamLike } from './shims'; import type { OpenAI } from '../client'; -import './polyfill/file.node.js'; +import { File } from './shims/file.node.js'; +import { ReadableStreamFrom } from './shims'; -type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; -type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable & { path: string | { toString(): string } }; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} /** * Typically, this is a native "File" class. @@ -16,188 +21,41 @@ type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; * For convenience, you can also pass a fetch Response, or in Node, * the result of fs.createReadStream(). */ -export type Uploadable = FileLike | ResponseLike | FsReadStreamLike; - -/** - * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. - * Don't add arrayBuffer here, node-fetch doesn't have it - */ -interface BlobLike { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ - readonly size: number; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ - readonly type: string; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ - text(): Promise; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ - slice(start?: number, end?: number): BlobLike; -} - -/** - * This check adds the arrayBuffer() method type because it is available and used at runtime - */ -const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => - value != null && - typeof value === 'object' && - typeof value.size === 'number' && - typeof value.type === 'string' && - typeof value.text === 'function' && - typeof value.slice === 'function' && - typeof value.arrayBuffer === 'function'; - -/** - * Intended to match DOM File, node:buffer File, undici File, etc. - */ -interface FileLike extends BlobLike { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ - readonly lastModified: number; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ - readonly name?: string | undefined; -} -declare var FileClass: { - prototype: FileLike; - new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike; -}; - -/** - * This check adds the arrayBuffer() method type because it is available and used at runtime - */ -const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => - value != null && - typeof value === 'object' && - typeof value.name === 'string' && - typeof value.lastModified === 'number' && - isBlobLike(value); - -/** - * Intended to match DOM Response, node-fetch Response, undici Response, etc. - */ -export interface ResponseLike { - url: string; - blob(): Promise; -} - -const isResponseLike = (value: any): value is ResponseLike => - value != null && - typeof value === 'object' && - typeof value.url === 'string' && - typeof value.blob === 'function'; - -const isUploadable = (value: any): value is Uploadable => { - return isFileLike(value) || isResponseLike(value) || isFsReadStreamLike(value); -}; - -type ToFileInput = Uploadable | Exclude | AsyncIterable; +export type Uploadable = File | Response | FsReadStream | BunFile; /** * Construct a `File` instance. This is used to ensure a helpful error is thrown - * for environments that don't define a global `File` yet and so that we don't - * accidentally rely on a global `File` type in our annotations. + * for environments that don't define a global `File` yet. */ -function makeFile(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): FileLike { - const File = (globalThis as any).File as typeof FileClass | undefined; +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { if (typeof File === 'undefined') { throw new Error('`File` is not defined as a global which is required for file uploads'); } - return new File(fileBits, fileName, options); + return new File(fileBits as any, fileName ?? 'unknown_file', options); } -/** - * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats - * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s - * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible - * @param {Object=} options additional properties - * @param {string=} options.type the MIME type of the content - * @param {number=} options.lastModified the last modified timestamp - * @returns a {@link File} with the given properties - */ -export async function toFile( - value: ToFileInput | PromiseLike, - name?: string | null | undefined, - options?: FilePropertyBag | undefined, -): Promise { - // If it's a promise, resolve it. - value = await value; - - // If we've been given a `File` we don't need to do anything - if (isFileLike(value)) { - const File = (globalThis as any).File as typeof FileClass | undefined; - if (File && value instanceof File) { - return value; - } - return makeFile([await value.arrayBuffer()], value.name ?? 'unknown_file'); - } - - if (isResponseLike(value)) { - const blob = await value.blob(); - name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file'; - - return makeFile(await getBytes(blob), name, options); - } - - const parts = await getBytes(value); - - name ||= getName(value) ?? 'unknown_file'; - - if (!options?.type) { - const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); - if (typeof type === 'string') { - options = { ...options, type }; - } - } - - return makeFile(parts, name, options); -} - -export async function getBytes( - value: Uploadable | BlobLikePart | AsyncIterable, -): Promise> { - let parts: Array = []; - if ( - typeof value === 'string' || - ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. - value instanceof ArrayBuffer - ) { - parts.push(value); - } else if (isBlobLike(value)) { - parts.push(value instanceof Blob ? value : await value.arrayBuffer()); - } else if ( - isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc. - ) { - for await (const chunk of value) { - parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? - } - } else { - const constructor = value?.constructor?.name; - throw new Error( - `Unexpected data type: ${typeof value}${ - constructor ? `; constructor: ${constructor}` : '' - }${propsForError(value)}`, - ); - } - - return parts; -} - -function propsForError(value: unknown): string { - if (typeof value !== 'object' || value === null) return ''; - const props = Object.getOwnPropertyNames(value); - return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; -} - -function getName(value: unknown): string | undefined { +export function getName(value: any): string | undefined { return ( - (typeof value === 'object' && - value !== null && - (('name' in value && String(value.name)) || - ('filename' in value && String(value.filename)) || - ('path' in value && String(value.path).split(/[\\/]/).pop()))) || - undefined + ( + (typeof value === 'object' && + value !== null && + (('name' in value && value.name && String(value.name)) || + ('url' in value && value.url && String(value.url)) || + ('filename' in value && value.filename && String(value.filename)) || + ('path' in value && value.path && String(value.path)))) || + '' + ) + .split(/[\\/]/) + .pop() || undefined ); } -const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator => +export const isAsyncIterable = (value: any): value is AsyncIterable => value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; /** @@ -268,6 +126,16 @@ export const createForm = async >( return form; }; +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isNamedBlob = (value: object) => + (File && value instanceof File) || (value instanceof Blob && 'name' in value); + +const isUploadable = (value: unknown) => + typeof value === 'object' && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isNamedBlob(value)); + const hasUploadableValue = (value: unknown): boolean => { if (isUploadable(value)) return true; if (Array.isArray(value)) return value.some(hasUploadableValue); @@ -290,9 +158,12 @@ const addFormValue = async (form: FormData, key: string, value: unknown): Promis // TODO: make nested formats configurable if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { form.append(key, String(value)); - } else if (isUploadable(value)) { - const file = await toFile(value); - form.append(key, file as any); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value))); + } else if (isNamedBlob(value)) { + form.append(key, value, getName(value)); } else if (Array.isArray(value)) { await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); } else if (typeof value === 'object') { diff --git a/src/internal/utils/uuid.ts b/src/internal/utils/uuid.ts index 6c43f81dd..1349c42c3 100644 --- a/src/internal/utils/uuid.ts +++ b/src/internal/utils/uuid.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { crypto } from '../polyfill/crypto.node'; +import { crypto } from '../shims/crypto.node.js'; /** * https://stackoverflow.com/a/2117523 diff --git a/src/lib/AbstractChatCompletionRunner.ts b/src/lib/AbstractChatCompletionRunner.ts index 144196aae..9f31ac119 100644 --- a/src/lib/AbstractChatCompletionRunner.ts +++ b/src/lib/AbstractChatCompletionRunner.ts @@ -13,11 +13,8 @@ import { type BaseFunctionsArgs, RunnableToolFunction, } from './RunnableFunction'; -import { ChatCompletionFunctionRunnerParams, ChatCompletionToolRunnerParams } from './ChatCompletionRunner'; -import { - ChatCompletionStreamingFunctionRunnerParams, - ChatCompletionStreamingToolRunnerParams, -} from './ChatCompletionStreamingRunner'; +import { ChatCompletionToolRunnerParams } from './ChatCompletionRunner'; +import { ChatCompletionStreamingToolRunnerParams } from './ChatCompletionStreamingRunner'; import { isAssistantMessage, isFunctionMessage, isToolMessage } from './chatCompletionUtils'; import { BaseEvents, EventStream } from './EventStream'; import { ParsedChatCompletion } from '../resources/beta/chat/completions'; @@ -266,91 +263,6 @@ export class AbstractChatCompletionRunner< return await this._createChatCompletion(client, params, options); } - protected async _runFunctions( - client: OpenAI, - params: - | ChatCompletionFunctionRunnerParams - | ChatCompletionStreamingFunctionRunnerParams, - options?: RunnerOptions, - ) { - const role = 'function' as const; - const { function_call = 'auto', stream, ...restParams } = params; - const singleFunctionToCall = typeof function_call !== 'string' && function_call?.name; - const { maxChatCompletions = DEFAULT_MAX_CHAT_COMPLETIONS } = options || {}; - - const functionsByName: Record> = {}; - for (const f of params.functions) { - functionsByName[f.name || f.function.name] = f; - } - - const functions: ChatCompletionCreateParams.Function[] = params.functions.map( - (f): ChatCompletionCreateParams.Function => ({ - name: f.name || f.function.name, - parameters: f.parameters as Record, - description: f.description, - }), - ); - - for (const message of params.messages) { - this._addMessage(message, false); - } - - for (let i = 0; i < maxChatCompletions; ++i) { - const chatCompletion: ChatCompletion = await this._createChatCompletion( - client, - { - ...restParams, - function_call, - functions, - messages: [...this.messages], - }, - options, - ); - const message = chatCompletion.choices[0]?.message; - if (!message) { - throw new OpenAIError(`missing message in ChatCompletion response`); - } - if (!message.function_call) return; - const { name, arguments: args } = message.function_call; - const fn = functionsByName[name]; - if (!fn) { - const content = `Invalid function_call: ${JSON.stringify(name)}. Available options are: ${functions - .map((f) => JSON.stringify(f.name)) - .join(', ')}. Please try again`; - - this._addMessage({ role, name, content }); - continue; - } else if (singleFunctionToCall && singleFunctionToCall !== name) { - const content = `Invalid function_call: ${JSON.stringify(name)}. ${JSON.stringify( - singleFunctionToCall, - )} requested. Please try again`; - - this._addMessage({ role, name, content }); - continue; - } - - let parsed; - try { - parsed = isRunnableFunctionWithParse(fn) ? await fn.parse(args) : args; - } catch (error) { - this._addMessage({ - role, - name, - content: error instanceof Error ? error.message : String(error), - }); - continue; - } - - // @ts-expect-error it can't rule out `never` type. - const rawContent = await fn.function(parsed, this); - const content = this.#stringifyFunctionCallResult(rawContent); - - this._addMessage({ role, name, content }); - - if (singleFunctionToCall) return; - } - } - protected async _runTools( client: OpenAI, params: diff --git a/src/lib/ChatCompletionRunner.ts b/src/lib/ChatCompletionRunner.ts index 9e68e6671..a5edaf741 100644 --- a/src/lib/ChatCompletionRunner.ts +++ b/src/lib/ChatCompletionRunner.ts @@ -2,7 +2,7 @@ import { type ChatCompletionMessageParam, type ChatCompletionCreateParamsNonStreaming, } from '../resources/chat/completions'; -import { type RunnableFunctions, type BaseFunctionsArgs, RunnableTools } from './RunnableFunction'; +import { type BaseFunctionsArgs, RunnableTools } from './RunnableFunction'; import { AbstractChatCompletionRunner, AbstractChatCompletionRunnerEvents, @@ -16,13 +16,6 @@ export interface ChatCompletionRunnerEvents extends AbstractChatCompletionRunner content: (content: string) => void; } -export type ChatCompletionFunctionRunnerParams = Omit< - ChatCompletionCreateParamsNonStreaming, - 'functions' -> & { - functions: RunnableFunctions; -}; - export type ChatCompletionToolRunnerParams = Omit< ChatCompletionCreateParamsNonStreaming, 'tools' @@ -34,21 +27,6 @@ export class ChatCompletionRunner extends AbstractChatCompletion ChatCompletionRunnerEvents, ParsedT > { - /** @deprecated - please use `runTools` instead. */ - static runFunctions( - client: OpenAI, - params: ChatCompletionFunctionRunnerParams, - options?: RunnerOptions, - ): ChatCompletionRunner { - const runner = new ChatCompletionRunner(); - const opts = { - ...options, - headers: { ...options?.headers, 'X-Stainless-Helper-Method': 'runFunctions' }, - }; - runner._run(() => runner._runFunctions(client, params, opts)); - return runner; - } - static runTools( client: OpenAI, params: ChatCompletionToolRunnerParams, diff --git a/src/lib/ChatCompletionStreamingRunner.ts b/src/lib/ChatCompletionStreamingRunner.ts index 32b66b006..edd25ab93 100644 --- a/src/lib/ChatCompletionStreamingRunner.ts +++ b/src/lib/ChatCompletionStreamingRunner.ts @@ -3,8 +3,8 @@ import { type ChatCompletionCreateParamsStreaming, } from '../resources/chat/completions'; import { RunnerOptions, type AbstractChatCompletionRunnerEvents } from './AbstractChatCompletionRunner'; -import { type ReadableStream } from '../internal/shim-types'; -import { RunnableTools, type BaseFunctionsArgs, type RunnableFunctions } from './RunnableFunction'; +import type { ReadableStream } from '../internal/shim-types'; +import { RunnableTools, type BaseFunctionsArgs } from './RunnableFunction'; import { ChatCompletionSnapshot, ChatCompletionStream } from './ChatCompletionStream'; import OpenAI from '../index'; import { AutoParseableTool } from '../lib/parser'; @@ -14,13 +14,6 @@ export interface ChatCompletionStreamEvents extends AbstractChatCompletionRunner chunk: (chunk: ChatCompletionChunk, snapshot: ChatCompletionSnapshot) => void; } -export type ChatCompletionStreamingFunctionRunnerParams = Omit< - ChatCompletionCreateParamsStreaming, - 'functions' -> & { - functions: RunnableFunctions; -}; - export type ChatCompletionStreamingToolRunnerParams = Omit< ChatCompletionCreateParamsStreaming, 'tools' @@ -38,21 +31,6 @@ export class ChatCompletionStreamingRunner return runner; } - /** @deprecated - please use `runTools` instead. */ - static runFunctions( - client: OpenAI, - params: ChatCompletionStreamingFunctionRunnerParams, - options?: RunnerOptions, - ): ChatCompletionStreamingRunner { - const runner = new ChatCompletionStreamingRunner(null); - const opts = { - ...options, - headers: { ...options?.headers, 'X-Stainless-Helper-Method': 'runFunctions' }, - }; - runner._run(() => runner._runFunctions(client, params, opts)); - return runner; - } - static runTools( client: OpenAI, params: ChatCompletionStreamingToolRunnerParams, diff --git a/src/lib/EventStream.ts b/src/lib/EventStream.ts index d3f485e9d..d1b1cc8d3 100644 --- a/src/lib/EventStream.ts +++ b/src/lib/EventStream.ts @@ -1,3 +1,4 @@ +import { isAbortError } from '../internal/errors'; import { APIUserAbortError, OpenAIError } from '../error'; export class EventStream { @@ -145,7 +146,7 @@ export class EventStream { #handleError(this: EventStream, error: unknown) { this.#errored = true; - if (error instanceof Error && error.name === 'AbortError') { + if (isAbortError(error)) { error = new APIUserAbortError(); } if (error instanceof APIUserAbortError) { diff --git a/src/lib/RunnableFunction.ts b/src/lib/RunnableFunction.ts index a645f5ebe..4d1519d5a 100644 --- a/src/lib/RunnableFunction.ts +++ b/src/lib/RunnableFunction.ts @@ -85,13 +85,6 @@ export function isRunnableFunctionWithParse( export type BaseFunctionsArgs = readonly (object | string)[]; -export type RunnableFunctions = - [any[]] extends [FunctionsArgs] ? readonly RunnableFunction[] - : { - [Index in keyof FunctionsArgs]: Index extends number ? RunnableFunction - : FunctionsArgs[Index]; - }; - export type RunnableTools = [any[]] extends [FunctionsArgs] ? readonly RunnableToolFunction[] : { @@ -99,28 +92,6 @@ export type RunnableTools = : FunctionsArgs[Index]; }; -/** - * This is helper class for passing a `function` and `parse` where the `function` - * argument type matches the `parse` return type. - * - * @deprecated - please use ParsingToolFunction instead. - */ -export class ParsingFunction { - function: RunnableFunctionWithParse['function']; - parse: RunnableFunctionWithParse['parse']; - parameters: RunnableFunctionWithParse['parameters']; - description: RunnableFunctionWithParse['description']; - name?: RunnableFunctionWithParse['name']; - - constructor(input: RunnableFunctionWithParse) { - this.function = input.function; - this.parse = input.parse; - this.parameters = input.parameters; - this.description = input.description; - this.name = input.name; - } -} - /** * This is helper class for passing a `function` and `parse` where the `function` * argument type matches the `parse` return type. diff --git a/src/resources/beta/chat/completions.ts b/src/resources/beta/chat/completions.ts index 15ec3f7e4..b2e0148bd 100644 --- a/src/resources/beta/chat/completions.ts +++ b/src/resources/beta/chat/completions.ts @@ -3,7 +3,6 @@ import { APIResource } from '../../../resource'; import { ChatCompletionRunner } from '../../../lib/ChatCompletionRunner'; import { ChatCompletionStreamingRunner } from '../../../lib/ChatCompletionStreamingRunner'; -import { RunnerOptions } from '../../../lib/AbstractChatCompletionRunner'; import { ChatCompletionToolRunnerParams } from '../../../lib/ChatCompletionRunner'; import { ChatCompletionStreamingToolRunnerParams } from '../../../lib/ChatCompletionStreamingRunner'; import { ChatCompletionStream, type ChatCompletionStreamParams } from '../../../lib/ChatCompletionStream'; @@ -14,28 +13,21 @@ import { ChatCompletionMessageToolCall, } from '../../chat/completions'; import { ExtractParsedContentFromParams, parseChatCompletion, validateInputTools } from '../../../lib/parser'; +import { RequestOptions } from '../../../internal/request-options'; +import { type APIPromise } from '../../../api-promise'; +import { RunnerOptions } from '../../../lib/AbstractChatCompletionRunner'; -export { - ChatCompletionStreamingRunner, - type ChatCompletionStreamingFunctionRunnerParams, -} from '../../../lib/ChatCompletionStreamingRunner'; +export { ChatCompletionStream, type ChatCompletionStreamParams } from '../../../lib/ChatCompletionStream'; +export { ChatCompletionRunner } from '../../../lib/ChatCompletionRunner'; +export { ChatCompletionStreamingRunner } from '../../../lib/ChatCompletionStreamingRunner'; export { type RunnableFunction, - type RunnableFunctions, type RunnableFunctionWithParse, type RunnableFunctionWithoutParse, - ParsingFunction, ParsingToolFunction, } from '../../../lib/RunnableFunction'; export { type ChatCompletionToolRunnerParams } from '../../../lib/ChatCompletionRunner'; export { type ChatCompletionStreamingToolRunnerParams } from '../../../lib/ChatCompletionStreamingRunner'; -export { ChatCompletionStream, type ChatCompletionStreamParams } from '../../../lib/ChatCompletionStream'; -export { - ChatCompletionRunner, - type ChatCompletionFunctionRunnerParams, -} from '../../../lib/ChatCompletionRunner'; -import { RequestOptions } from '../../../internal/request-options'; -import { type APIPromise } from '../../../index'; export interface ParsedFunction extends ChatCompletionMessageToolCall.Function { parsed_arguments?: unknown; diff --git a/src/uploads.ts b/src/uploads.ts index 77b65766a..79d3073ea 100644 --- a/src/uploads.ts +++ b/src/uploads.ts @@ -1 +1,2 @@ -export { type Uploadable, toFile } from './internal/uploads'; +export { type Uploadable } from './internal/uploads'; +export { toFile, type ToFileInput } from './internal/to-file'; diff --git a/src/version.ts b/src/version.ts index 2205d0775..58d8f3c50 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '5.0.0-alpha.0'; // x-release-please-version +export const VERSION = '5.0.0-beta.0'; // x-release-please-version diff --git a/tests/lib/azure.test.ts b/tests/lib/azure.test.ts index bffd04371..1b9b2546c 100644 --- a/tests/lib/azure.test.ts +++ b/tests/lib/azure.test.ts @@ -1,6 +1,7 @@ import { AzureOpenAI } from 'openai'; import { APIUserAbortError } from 'openai'; import { type Response, RequestInit, RequestInfo } from 'openai/internal/builtin-types'; +import { File } from 'node:buffer'; const defaultFetch = fetch; diff --git a/tests/responses.test.ts b/tests/responses.test.ts index 3cb73f4e4..b8a3db906 100644 --- a/tests/responses.test.ts +++ b/tests/responses.test.ts @@ -2,7 +2,7 @@ import { APIPromise } from 'openai/api-promise'; import OpenAI from 'openai/index'; import { compareType } from './utils/typing'; -const client = new OpenAI(); +const client = new OpenAI({ apiKey: '...' }); describe('request id', () => { test('types', () => { diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts index 44d10769b..508fce58f 100644 --- a/tests/uploads.test.ts +++ b/tests/uploads.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; -import type { ResponseLike } from 'openai/internal/uploads'; +import type { ResponseLike } from 'openai/internal/to-file'; import { toFile } from 'openai/uploads'; +import { File } from 'node:buffer'; class MyClass { name: string = 'foo'; @@ -62,15 +63,45 @@ describe('toFile', () => { expect(file.name).toEqual('input.jsonl'); expect(file.type).toBe('jsonl'); }); + + it('is assignable to File and Blob', async () => { + const input = new File(['foo'], 'input.jsonl', { type: 'jsonl' }); + const result = await toFile(input); + const file: File = result; + const blob: Blob = result; + void file, blob; + }); }); -test('missing File error message', async () => { - // @ts-ignore - globalThis.File = undefined; +describe('missing File error message', () => { + let prevGlobalFile: unknown; + let prevNodeFile: unknown; + beforeEach(() => { + // The file shim captures the global File object when it's first imported. + // Reset modules before each test so we can test the error thrown when it's undefined. + jest.resetModules(); + const buffer = require('node:buffer'); + // @ts-ignore + prevGlobalFile = globalThis.File; + prevNodeFile = buffer.File; + // @ts-ignore + globalThis.File = undefined; + buffer.File = undefined; + }); + afterEach(() => { + // Clean up + // @ts-ignore + globalThis.File = prevGlobalFile; + require('node:buffer').File = prevNodeFile; + jest.resetModules(); + }); - await expect( - toFile(mockResponse({ url: 'https://example.com/my/audio.mp3' })), - ).rejects.toMatchInlineSnapshot( - `[Error: \`File\` is not defined as a global which is required for file uploads]`, - ); + test('is thrown', async () => { + const uploads = await import('openai/uploads'); + await expect( + uploads.toFile(mockResponse({ url: 'https://example.com/my/audio.mp3' })), + ).rejects.toMatchInlineSnapshot( + `[Error: \`File\` is not defined as a global which is required for file uploads]`, + ); + }); }); diff --git a/yarn.lock b/yarn.lock index c16fb37a0..b8808db07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3217,10 +3217,10 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -synckit@^0.9.1: - version "0.9.2" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.2.tgz#a3a935eca7922d48b9e7d6c61822ee6c3ae4ec62" - integrity sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw== +synckit@0.8.8, synckit@^0.9.1: + version "0.8.8" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.8.tgz#fe7fe446518e3d3d49f5e429f443cf08b6edfcd7" + integrity sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ== dependencies: "@pkgr/core" "^0.1.0" tslib "^2.6.2"