diff --git a/.changeset/openai-four-to-seven-candidate.md b/.changeset/openai-four-to-seven-candidate.md new file mode 100644 index 00000000..14c14022 --- /dev/null +++ b/.changeset/openai-four-to-seven-candidate.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Add a candidate openai 4 to 7 migration pack: named path parameters, `del` → `delete`, promoted beta chat helpers, renamed runner events, removed entry points and request-option overloads are rewritten where unambiguous; tool-call unions, `httpAgent`, `fileFromPath` and the other removals get agent guides; `APIError.headers`, Web `Response` bodies and double-encoded path ids are detected even in code typed `any`; Node 22, zod, fetch and client-subclass decisions carry repository evidence. It stays a candidate (the dependency is still checked and fixed as generic) until two engine gaps its ground truth exposed are closed. diff --git a/README.md b/README.md index 8c3f2476..59eb5de0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ The contract: [migration packs](docs/packs.md). | `joi` | 17.x → 18.x | n/a | n/a | — | edtech4good/edtech-lms-api, senacor/azure-function-middleware | candidate | | `lucide-react` | 0.x → 1.x | 100% | 100% | 11 | tradeblocks-org/tradeblocks, spizeck/SFPCA, cubicsteak/keep | verified | | `next` | 15.x → 16.x | 100% | 88% | 34 | sambecker/exif-photo-blog, stoczec/wortkarte, benshabbat/GamesForMyKids, HiroshiIgarash/koremina | verified | +| `openai` | 4.x → 7.x | 100% | 15% | 20 | zeikar/charivo, openai/openai-guardrails-js, wardby/wardby, csnyder256/ux-struggle-detector, grantsingleton/batch-ai, digitalchokro/askchokro, microsoft/prompty | candidate | | `react` | 18.x → 19.x | 100% | 69% | 130 | excalidraw/excalidraw, tldraw/tldraw | verified | | `react-router` | 6.x → 7.x | 100% | 88% | 18 | Arize-ai/phoenix, getlago/lago-front | verified | | `redis` | >=4 <6 → >=5 <7 | n/a | n/a | — | deanmoses/tacocat-gallery-sam, swoofer/mcp-coordinator, deanmoses/tacocat-gallery-sam, UNN-Devotek/FCM-Fallout-Chat-Mod, tearingItUp786/blog | candidate | diff --git a/packages/core/src/packs/openai/RESEARCH.md b/packages/core/src/packs/openai/RESEARCH.md new file mode 100644 index 00000000..a6041618 --- /dev/null +++ b/packages/core/src/packs/openai/RESEARCH.md @@ -0,0 +1,206 @@ +# openai 4 → 7: evidence and verification limits + +Read date for every source below: **2026-10-14**. Scope: `openai` (openai-node) `>=4 <5` to +`>=7 <8`; sources pinned at tag **v7.32.0** (7.33.0 was published the same day and is not +read). Status: **candidate**, by choice. Last re-scored 2026-10-15 on main `9039e3c` (after +#138 compile scope, #142 required moves, #154 records, #155 stricter gate, #156, #157 nested +copies): breaking 3 TP / 0 FP / 17 FN, and the run measures **verified** under the current +gate. The record says candidate anyway (below). The pack's own rules and detectors add no +false positive on the seven public upgrades. No engine change is part of this pack. + +## Source ledger + +- [MIGRATION.md at v7.32.0](https://github.com/openai/openai-node/blob/v7.32.0/MIGRATION.md): + the 4 → 5 guide, kept current; at v7.0.0 it raised the runtime floor to Node.js 22 and + dropped the "Migration CLI" section (the `openai migrate` codemod was removed in 6.x: + changelog "remove migrate CLI"). Compared with + [v5.0.0](https://github.com/openai/openai-node/blob/v5.0.0/MIGRATION.md), + [v6.0.0](https://github.com/openai/openai-node/blob/v6.0.0/MIGRATION.md) and + [v7.0.0](https://github.com/openai/openai-node/blob/v7.0.0/MIGRATION.md): the later copies + only add newer endpoints to the same lists (admin, skills, videos, chatkit). +- [CHANGELOG.md at v7.32.0](https://github.com/openai/openai-node/blob/v7.32.0/CHANGELOG.md), + every entry from 4.104.0 to 7.32.0, and the release notes of + [5.0.0](https://github.com/openai/openai-node/releases/tag/v5.0.0), + [5.12.1](https://github.com/openai/openai-node/releases/tag/v5.12.1), + [6.0.0](https://github.com/openai/openai-node/releases/tag/v6.0.0), + [7.0.0](https://github.com/openai/openai-node/releases/tag/v7.0.0). +- Published packages (read with `npm pack`, nothing executed): openai 4.104.0, 5.0.0, + 5.11.0, 5.12.0, 5.12.1, 6.0.0, 6.49.0, 7.0.0, 7.32.0; their `package.json` (engines, peers, + exports) and `.d.ts` (every signature quoted below). + +## One pack, not three + +| Major | Breaking entries in its changelog | What it changes for 4.x code | +| --- | --- | --- | +| 5.0.0 (2025-05-29) | the whole MIGRATION.md (built-in fetch, named path params, renamed methods, removed shims/helpers, beta chat promoted) | almost everything below | +| 6.0.0 (2025-09-30) | one: `ResponseFunctionToolCallOutputItem.output` and `ResponseCustomToolCallOutput.output` become `string \| Array<…>` | one type widening | +| 7.0.0 (2026-07-27) | one: "require Node.js 22 and codify version support" (`engines.node >=22.0.0`) | the runtime floor | + +Between majors, 5.12.1 turned `ChatCompletionMessageToolCall` and `ChatCompletionTool` into +function | custom unions (checked: 5.12.0 has interfaces, 5.12.1 the unions), and 6.0.0's +manifest widened the zod peer from `^3.23.8` to `^3.25 || ^4.0`. No later major reverts or +re-renames anything 5.0 introduced, the guide is one document for the whole range, and the +public upgrades found all jump 4.x → 7.x directly (npm mining found ten packages whose +published manifests went `^4` → `^7` with nothing in between). A split would only serve 5.x +and 6.x installs, which the queue entry does not cover; they keep the generic tier. One pack. + +## Classified changes + +**M** mechanical rule (rewrite, declines when ambiguous) · **A** assisted rule (guide for the +agent; options surface in "Decisions for you") · **B** behavior note (finding with `detect`, +or decision with repository evidence) · **N** not compiler-visible and not covered. + +| Change (source) | Class | Treatment | +| --- | --- | --- | +| Named path params: `runs.retrieve(threadId, runId)` → `runs.retrieve(runId, { thread_id })`: the 25 methods of the guide's list that exist in 4.x, plus 3 it omits (guide; types) | M `named-path-params` | Rewrites only inert arguments (names, members, literals) and an unambiguous third argument; a body merges into the params. `runs.poll`, `submitToolOutputsAndPoll`, `submitToolOutputsStream` changed too although the guide's list omits them (7.32 `.d.ts`). Syntactic detect also sees clients typed `any`. | +| `del()` → `delete()`, 14 methods (guide) | M `delete-method` | Rename; multi-id resources reshape as above. A bare `files.del` is detected only on a known client (other libraries have `del`). | +| `beta.chat.completions.parse/stream/runTools` → `chat.completions.*` (guide) | M `beta-chat` | Rename of the chain; `?.beta` declines. | +| Beta chat types moved to `openai/resources/chat/completions` (guide) | M `removed-modules` | Only when every imported name exists at the new path (the runFunctions types do not). | +| `openai/shims/*` removed (guide) | M `removed-modules` + B `global-types` | A side-effect import alone on its line is deleted; named imports are the agent's. | +| `openai/src/*` removed (guide) | M `removed-modules` | `openai/src` and `openai/src/index` → `openai`; deeper paths are the agent's. | +| `openai/core` refactored, `APIClient` removed (guide) | A `removed-modules`, `removed-exports` | Guide names the replacements. | +| `openai/error`, `pagination`, `resource`, `streaming`, `uploads` → `openai/core/*` (guide) | M `core-paths`, deprecated | 7.32 still ships the old paths as `@deprecated` re-exports, so they compile: deprecated, applied with `--include-deprecated`. | +| `uploads` internals removed (`fileFromPath`, `BlobLike`, `createForm`, …) (guide) | A `removed-exports`, `file-from-path` | `fileFromPath` offers two options (stream, or `toFile` keeping the name): the choice goes to decisions. | +| Removed request-options overloads: `list({ headers })` → `list({}, { headers })` (guide) | M `request-options` | 7.x types those params with request-option keys as `?: never`, so the compiler message does not name the key: found syntactically (literal of request-option keys only, on an SDK resource). | +| `httpAgent` removed for `fetchOptions` (guide) | A `http-agent` + B `builtin-fetch` | Three options (undici `ProxyAgent` dispatcher, undici `Agent`, custom fetch): the choice goes to decisions. The note names proxy/agent dependencies and a global dispatcher, which now applies to OpenAI traffic. | +| `withResponse`/`asResponse` return Web `Response`; `body` is a `ReadableStream` (guide) | B `web-response` (finding) | Detects `body.pipe/on/read`, `headers.raw()`, `buffer()` on names bound to those calls, typed or not. | +| `APIError.headers` is a Web `Headers` (guide) | B `error-headers` (finding) | `catch (e) { e.headers['x'] }` is blind to the compiler (catch bindings are `any`/`unknown`; non-strict code compiles); detected syntactically. | +| Path params URI-encoded (guide) | B `encoded-path-params` (finding) | `encodeURIComponent(...)` passed to an SDK method is now encoded twice. | +| Zod helpers throw on optional-only fields (guide) | B `zod-helpers` (decision) | Evidence: files importing `openai/helpers/zod`; also a declared zod below the 6.0 peer range. | +| Pagination `nextPageParams()`/`nextPageInfo()` → `nextPageRequestOptions()` (guide) | A `page-helpers` | Detected by name (any receiver). | +| Page classes become type aliases (guide) | A `page-classes` | Compiler TS2693. | +| `runFunctions` removed (guide) | A `run-functions` | Guide maps `functions` to `tools` and `function_call` to `tool_choice`. | +| runTools events/methods `functionCall*` → `functionToolCall*` (guide) | M `runner-renames` | String literals in `.on/.once/.off/.emitted` and the two methods; payloads keep their shape (checked). | +| Resource classes from the package root in CommonJS (guide) | N | Runtime-only in CJS, never valid in types; named in the `also-review` decision. | +| Tool calls and tools are function \| custom unions (5.12.1) | A `tool-call-union` | Narrow on `type`; the custom case must be decided, never cast. | +| Function/custom tool call outputs widen (6.0.0) | A `function-output-union` | Handle the array case. | +| Node.js 22, TypeScript 4.9 minimum (7.0.0; guide) | B `platform-minimums` (decision) | Evidence from engines, `.nvmrc`/`.node-version`, CI `node-version`, declared TypeScript. | +| Global fetch types needed: DOM lib or `@types/node >= 20` (guide, "TypeScript troubleshooting") | B `global-types` (decision) | Evidence from tsconfig `lib` and `@types/node`. | +| `withOptions()` clones by calling the class with client options only (5.0.0 "add withOptions helper"; `client.js` 7.32) | B `client-subclass` (decision) | Evidence: a class extending `OpenAI`/`AzureOpenAI`. Not in the guide; read from the shipped code. | +| `exports` went from `./*` (4.104) to 68 explicit subpaths (7.32; changelog "stop using path fallbacks") | `requiresBuild: true` | A deep import (`openai/internal/...`) that `moduleResolution: node` still resolves from disk fails in Node or a bundler; only the build shows it. | +| zod peer `^3.23.8` → `^3.25 \|\| ^4.0` (6.0.0 manifest) | B `zod-helpers` | Engine gap: `check` does not move it (below). | +| Assistants API deprecated (changelog "mark assistants as deprecated") | N | API lifecycle, not an SDK change. | + +**Release group and companions: none.** The openai-node repository publishes `openai` only. +`@openai/agents` depends on `openai` (0.1.3: `^5.20.2`; 0.17.2: `^7.2.0`) as a regular +dependency, on its own 0.x line: it is not released in lockstep and needs no entry. Peers of +7.x: `ws`, `zod`, `@smithy/*`, `@aws-sdk/credential-provider-node`, `undici` (7.32), all +optional. + +## Ground truth + +Found by web search for merged Dependabot/Renovate/manual 4.x → 7.x upgrades and by mining +npm: two keyword sweeps of the registry search (3,516 and 2,695 package names, overlapping), of which ten published `openai ^4` then `^7`. +Each parent was installed from its lockfile with scripts off, and drafted with +`pnpm packs:truth` (and `--also` for what the upgrade moved because of openai). Sites were +read and assigned by hand, **before the pack was scored**, and not changed afterwards. + +| Repository | Parent | Upgrade | Versions | Expected | +| --- | --- | --- | --- | --- | +| [zeikar/charivo](https://github.com/zeikar/charivo/compare/b9a476d9acb59f473e08f0e97ce6e91dc2201d99...67ae240f175f166ca0d7c5e79c1ea160e09ef133) | `b9a476d9` | `ef509fde` + `67ae240f` | 4.104.0 → 7.10.0 | 3 `tool-call-union` (TS2339, TS18046, a changed signature) | +| [openai/openai-guardrails-js#122](https://github.com/openai/openai-guardrails-js/pull/122) | `6c13ba08` | `a1b81722` | 4.104.0 → 7.9.0, with `@openai/agents` 0.17.2, zod 4.5.4 | 1 TS2345 (AzureOpenAI options) + 10 changed runs of a client-subclass rework, `generic` | +| [wardby/wardby#231](https://github.com/wardby/wardby/pull/231) | `d46ec64d` | `edc5f3d2` | 4.104.0 → 7.30.0 | 2 optional type cleanups, `generic` | +| [csnyder256/ux-struggle-detector#11](https://github.com/csnyder256/ux-struggle-detector/pull/11) | `b7308489` | `de8f29dd` | 4.104.0 → 7.23.0 | 2 `max_tokens` → `max_completion_tokens`, `generic` | +| [grantsingleton/batch-ai#14](https://github.com/grantsingleton/batch-ai/pull/14) | `05c7e9f1` | `0470d510` | 4.83.0 → 7.4.0, with zod 4.4.3 | 2 optional model-id widenings, `generic` | +| [digitalchokro/askchokro#31](https://github.com/digitalchokro/askchokro/pull/31) | `047fc448` | `190714f3` | 4.104.0 → 7.4.0 | none (compiles unchanged) | +| [microsoft/prompty#527](https://github.com/microsoft/prompty/pull/527) (`runtime/typescript`) | `e878b6b7` | `1f40686f` | 4.104.0 → 7.10.0 | none (compiles unchanged) | + +Choices made while reading, before scoring: comment-only lines are not sites (wardby's +`http-runtime.ts`, which records that openai 5+ traffic now goes through the global undici +dispatcher); zod 4 and `@openai/agents` edits in guardrails are another package's; the +guardrails `examples/` directory is outside its only tsconfig (`include: ["src/**/*"]`), so +the repository's compiler never sees it and the draft has no site there; ux-struggle-detector's +`max_tokens` change is kept as `generic` although the declaration is identical in 4.104.0 and +7.x (a request change the pack must never make); batch-ai's and guardrails' zod and +`@openai/agents` moves are recorded in `with` (openai 7's zod peer rejects batch-ai's 3.24.2; +`@openai/agents` 0.17 is the line that depends on openai 7). + +Screened out: [lifestyle3nergy-web/TWGT-#73](https://github.com/lifestyle3nergy-web/TWGT-/pull/73) +(no openai import in source; the upgrade only added a contract test); +[respanai/respan](https://github.com/respanai/respan/commit/638218d238f02b43b461a7f7fa75c3872f6155ad) +(rewrote its instrumentation to support openai 4 to 7 side by side; not an upgrade); +[moxxy-ai/moxxy#634](https://github.com/moxxy-ai/moxxy/pull/634) and +[WasmAgent/wasmagent-js#441](https://github.com/WasmAgent/wasmagent-js/pull/441) (monorepo +dependency waves with ai 7, zod 4 and Node 22 moves; not drafted); +microsoft/teams.ts #850/#913/#931 and eovidiu/open-brain #174 (closed, not merged); +sx4im/skillcheck #107 and KeshiaServices/generative-ai-for-beginners #207 (open); +nexus-substrate/nexus-agents #7266, Edric-Li/code-argus #4, mayurrawte/teamsly #212 +(6.x → 7, outside the range); headroom-ai and @codedprojects/api-mapper-openai (no +repository on npm); uptide-test/alanagoyal (the smoke test, by instruction). + +## Measured: `pnpm uptide pack test openai --write` + +Rebased on main `9039e3c` (2026-10-15), ground truth unchanged (digest +`sha256-ff39a727…`): + +| Rule / scoring group | Precision | Recall | TP | FP | FN | +| --- | ---: | ---: | ---: | ---: | ---: | +| tool-call-union | 100% | 33% | 1 | 0 | 2 | +| generic | 2% | 6% | 1 | 53 | 16 | +| Overall sites | 5% | 15% | 3 | 52 | 17 | +| Behavior notes | n/a | n/a | 0 | 0 | 0 | +| **Breaking sites** | **100%** | **15%** | **3** | **0** | **17** | + +Fixtures: 6 cases, 37 sites, pass. No other rule or note has an expected site in this truth; +none of them predicted one either. `pack test` measures **verified** (7 public repositories, +0 breaking false positives, 20 real breaking sites) and passes with a **candidate** record, +which #154 allows (a record may claim less than the run measures). Why candidate: the gate +counts seven rewrite rules as unexercised (`beta-chat`, `core-paths`, `delete-method`, +`named-path-params`, `removed-modules`, `request-options`, `runner-renames`): no public +repository in this truth has a site for them, so they would run as assisted. Recall is 15%, +and 17 of the 20 expected sites are generic. Promoting is a maintainer's call. + +Before #138 the same truth scored 42.86% breaking precision: `check` compiled guardrails' +`examples/`, which no tsconfig includes, and reported four real 7.9.0 errors there that the +tsconfig-scoped truth does not list. With the compile scope respected they are gone and +`src/base-client.ts:368` (TS2345) is still found. + +False positives (all non-breaking): 52 in prompty, where `check` cannot compile (its workspace +packages import an unbuilt `@prompty/core`) and lists type-diff sites as `unverified`. + +False negatives, and why: + +- charivo `openai-tool-format.ts:119`: found, as `generic`. The code narrows `raw` first, so + the compiler says `Property 'name' does not exist on type '{}'`, which names nothing of + openai; the rule does not claim such messages. +- charivo `:135`: the parameter type widened as a consequence of the fix at `:129`; no + diagnostic, only the agent's edit reaches it. +- guardrails `client.ts` ×10: the subclass rework for `withOptions()` cloning has no compiler + error; `client-subclass` names the file as a decision ("`src/client.ts` extends OpenAI"), + which `pack test` does not score. +- wardby ×2, batch-ai ×2: optional cleanups that 7.x types allow; nothing breaks. +- ux-struggle-detector ×2: a request-parameter change; the pack never changes what the + application sends. + +Companion moves in this run: + +- batch-ai: `check` now moves zod 3.24.2 → 3.25.0 because openai 7.4.0 requires + `zod ^3.25 || ^4.0` (#142). The real upgrade chose 4.4.3; `with` only requires the name. +- guardrails: explained, not blocking (#157). `@openai/agents` 0.1.11 keeps its own + nested openai `^5.20.2`; only a new major (0.15.0, which needs zod `^4.0.0`) accepts openai + 7.9.0, so `check` leaves it. The real upgrade chose that major (`@openai/agents` 0.17.2, + zod 4.5.4); `pack test` now reports the difference as an explained move. +- prompty: explained, not blocking (#157). `@azure/ai-projects` 2.4.0 keeps its own nested + openai `^6.16.0`, as the real upgrade's lockfile does (openai 6.49.0 under it). + +## What the engine could not express (separate tasks) + +1. ~~Required majors and nested regular dependencies.~~ Nested copies are stays (#157) and + blocked or unrelated `with` moves count as explained (#157). `check` still does not plan + a dependent's required major (guardrails' `@openai/agents`); `fix` leaves it nested. +2. ~~Compile scope.~~ Fixed by #138: `check` now respects TypeScript project scope. +3. **Decision evidence from sources.** `applies()` sees only the declared inventory, so the + pack declares source globs (`src`, `app`, `lib`, …) to find helper imports and subclasses; + files elsewhere are missed. A hook passing the usage files would be exact. +4. **`--no-llm` report labels.** A site of an assisted rule that was not rewritten is labelled + with its symbol path in the PR body, not the rule (the agent's task does use the rule). + +## Smoke test (not ground truth) + +`uptide fix openai --no-llm` on uptide-test/alanagoyal (openai 4.104.0 → 7.33.0), with the +pack forced active by an **uncommitted local edit** of `verification.json` (only verified packs +are active), restored afterwards. 5 compiler errors, all `ChatCompletionMessageToolCall#function` +(the 5.12.1 union, claimed by `tool-call-union`); no rewrite applies and no LLM key was +available, so all 5 stay manual; `next build` fails on them; tests pass. Decisions were scoped +by evidence: Node 24.x and TypeScript ^5 meet the floor, so only the general review line shows. +No branch was pushed and nothing was published. diff --git a/packages/core/src/packs/openai/behavior.ts b/packages/core/src/packs/openai/behavior.ts new file mode 100644 index 00000000..1bff9593 --- /dev/null +++ b/packages/core/src/packs/openai/behavior.ts @@ -0,0 +1,342 @@ +import { ts } from 'ts-morph'; +import { parse as parseYaml } from 'yaml'; +import { minVersion, satisfies } from '../../fetch/range.js'; +import type { BehaviorNote, SourceSite } from '../contract.js'; +import type { PackContext } from '../types.js'; +import { sdkChain } from './detect.js'; +import { + chainOf, + clientClasses, + clientNames, + methodCalls, + moduleReferences, + parse, + siteOf, + unwrap, + usesOpenai, +} from './syntax.js'; + +const guide = 'https://github.com/openai/openai-node/blob/v7.32.0/MIGRATION.md'; +const SOURCE = /\.[cm]?[jt]sx?$/; +const files = (context: PackContext) => context.inventory ?? []; +const sources = (context: PackContext) => + files(context).filter((f) => SOURCE.test(f.file) && usesOpenai(f.text)); +const packages = (context: PackContext) => + files(context).flatMap((f) => { + if (!/(?:^|\/)package\.json$/.test(f.file)) return []; + try { + return [{ file: f.file, json: JSON.parse(f.text) as Record> }]; + } catch { + return []; + } + }); +const declared = (pkg: Record>, name: string) => + pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]; + +/** + * `catch (e) { e.headers['retry-after'] }`: 4.x typed `APIError.headers` as a record, 5.x as a Web + * `Headers`, whose index access is `undefined`. A catch binding is `any` or `unknown`, so the + * compiler only sees this once the error is narrowed and the code is strict; flag it either way. + */ +function errorHeaderSites(text: string): SourceSite[] { + if (!usesOpenai(text)) return []; + const source = parse(text); + const sites: SourceSite[] = []; + const visit = (node: ts.Node, bindings: ReadonlySet): void => { + let inner = bindings; + if ( + ts.isCatchClause(node) && + node.variableDeclaration && + ts.isIdentifier(node.variableDeclaration.name) + ) + inner = new Set([...bindings, node.variableDeclaration.name.text]); + if (ts.isElementAccessExpression(node)) { + const headers = unwrap(node.expression); + if (ts.isPropertyAccessExpression(headers) && headers.name.text === 'headers') { + const owner = chainOf(headers.expression); + if (owner?.length === 1 && inner.has(owner[0] as string)) + sites.push(siteOf(source, headers.name, 'APIError#headers')); + } + } + ts.forEachChild(node, (child) => visit(child, inner)); + }; + visit(source, new Set()); + return sites; +} + +const NODE_FETCH_MEMBERS: Record = { + body: ['pipe', 'on', 'read', 'setEncoding'], + headers: ['raw'], +}; + +/** + * `.asResponse()` and `.withResponse()` return the runtime's `Response` in 5.x, not node-fetch's: + * `res.body.pipe(...)`, `res.headers.raw()` and `res.buffer()` are gone (MIGRATION.md, "Web types + * for withResponse, asResponse"). Found from the names bound to those calls in the same file. + */ +function webResponseSites(text: string): SourceSite[] { + if (!usesOpenai(text)) return []; + const source = parse(text); + const bound = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && node.initializer) { + let value = unwrap(node.initializer); + if (ts.isAwaitExpression(value)) value = unwrap(value.expression); + if (ts.isCallExpression(value) && ts.isPropertyAccessExpression(value.expression)) { + const method = value.expression.name.text; + if (method === 'asResponse' && ts.isIdentifier(node.name)) bound.add(node.name.text); + if (method === 'withResponse' && ts.isObjectBindingPattern(node.name)) + for (const element of node.name.elements) + if ( + (element.propertyName ?? element.name).getText(source) === 'response' && + ts.isIdentifier(element.name) + ) + bound.add(element.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + if (!bound.size) return []; + return methodCalls(source).flatMap(({ method, chain }) => { + if (chain.length === 1 && bound.has(chain[0] as string) && method.text === 'buffer') + return [siteOf(source, method, 'Response#buffer')]; + const member = chain[1]; + return chain.length === 2 && + bound.has(chain[0] as string) && + member !== undefined && + NODE_FETCH_MEMBERS[member]?.includes(method.text) + ? [siteOf(source, method, `Response#${member}.${method.text}`)] + : []; + }); +} + +/** + * `client.files.retrieve(encodeURIComponent(id))`: 5.x encodes path parameters itself, so an id + * the code encodes first is sent encoded twice (MIGRATION.md, "URI encoded path parameters"). + */ +function encodedPathSites(text: string): SourceSite[] { + if (!usesOpenai(text)) return []; + const source = parse(text); + const clients = clientNames(source); + return methodCalls(source).flatMap(({ call, chain }) => { + if (!sdkChain(chain, clients)) return []; + return call.arguments.flatMap((argument) => { + const value = unwrap(argument); + return ts.isCallExpression(value) && + ts.isIdentifier(value.expression) && + value.expression.text === 'encodeURIComponent' + ? [siteOf(source, value, 'encodeURIComponent')] + : []; + }); + }); +} + +/** Node 22 and TypeScript 4.9 are the floor of openai 7 (MIGRATION.md, "Environment requirements"). */ +function platformEvidence(context: PackContext): string | false { + const found: Record<'Node' | 'TypeScript', { where: string; version: string }[]> = { + Node: [], + TypeScript: [], + }; + for (const { file, json } of packages(context)) { + const engines = (json.engines as Record | undefined)?.node; + if (engines) found.Node.push({ where: `\`${file}\` engines.node`, version: engines }); + const typescript = declared(json, 'typescript'); + if (typescript) found.TypeScript.push({ where: `\`${file}\` TypeScript`, version: typescript }); + } + for (const { file, text } of files(context)) { + if (/(?:^|\/)(?:\.nvmrc|\.node-version)$/.test(file)) + found.Node.push({ where: `\`${file}\``, version: text.trim() }); + else if (/\.github\/workflows\/.*\.ya?ml$/.test(file)) + try { + for (const job of Object.values(parseYaml(text)?.jobs ?? {}) as { + steps?: { with?: Record }[]; + strategy?: { matrix?: Record }; + }[]) + for (const step of job.steps ?? []) { + const value = step.with?.['node-version']; + if (value === undefined) continue; + const matrix = /^\$\{\{\s*matrix\.([\w-]+)\s*\}\}$/.exec(String(value)); + const versions = matrix ? job.strategy?.matrix?.[matrix[1] as string] : value; + for (const version of Array.isArray(versions) ? versions : [versions]) + if (version !== undefined) + found.Node.push({ where: `\`${file}\` node-version`, version: String(version) }); + } + } catch { + /* An unreadable workflow is reported as no evidence below. */ + } + } + const issues = (['Node', 'TypeScript'] as const).flatMap((kind) => { + const minimum = kind === 'Node' ? '22.0.0' : '4.9.0'; + const readable = found[kind] + .map((hit) => ({ ...hit, low: minVersion(hit.version.replace(/^v(?=\d)/, '')) })) + .filter((hit) => hit.low); + const below = readable.filter((hit) => !satisfies(hit.low as string, `>=${minimum}`)); + if (below.length) + return below.map( + (hit) => `${hit.where} ${JSON.stringify(hit.version)} permits ${kind} below ${minimum}`, + ); + return readable.length + ? [] + : [`no ${kind} version is declared; openai 7 needs ${kind} >=${minimum}`]; + }); + return issues.length + ? `${issues.join('; ')}: openai 7 requires Node.js 22 and TypeScript 4.9; move CI, runtime pins and engines before deploying.` + : false; +} + +/** Files that import the zod helpers, and a declared zod below openai 6's `^3.25 || ^4.0` peer. */ +function zodEvidence(context: PackContext): string | false { + const helpers = sources(context) + .filter((f) => + moduleReferences(parse(f.text)).some((m) => m.specifier === 'openai/helpers/zod'), + ) + .map((f) => `\`${f.file}\``); + const low = packages(context).flatMap(({ file, json }) => { + const range = declared(json, 'zod'); + const least = range && minVersion(range); + return least && !satisfies(least, '^3.25.0 || ^4.0.0') + ? [ + `\`${file}\` declares zod ${JSON.stringify(range)}, below the openai 7 peer range ^3.25 || ^4.0`, + ] + : []; + }); + const parts = [ + ...low, + ...(helpers.length + ? [ + `${helpers.join(', ')} use openai/helpers/zod: a property that is \`.optional()\` without \`.nullable()\` now throws instead of warning (structured outputs require every field)`, + ] + : []), + ]; + return parts.length + ? `${parts.join('; ')}. Decide per schema; do not loosen the schema to make it pass.` + : false; +} + +const AGENTS = [ + 'https-proxy-agent', + 'http-proxy-agent', + 'socks-proxy-agent', + 'proxy-agent', + 'agentkeepalive', + 'node-fetch', +]; + +/** Proxy and agent packages, and `httpAgent` in files that use openai: 5.x fetches with the runtime's fetch. */ +function fetchEvidence(context: PackContext): string | false { + const deps = packages(context).flatMap(({ file, json }) => + AGENTS.filter((name) => declared(json, name)).map((name) => `\`${file}\` depends on ${name}`), + ); + const options = sources(context) + .filter((f) => /\bhttpAgent\b|\bsetGlobalDispatcher\b/.test(f.text)) + .map((f) => `\`${f.file}\` configures an HTTP agent or global dispatcher`); + const found = [...deps, ...options]; + return found.length + ? `${found.join('; ')}: openai 5+ sends requests with the runtime's built-in fetch, so \`httpAgent\` is gone (use \`fetchOptions\`, e.g. an undici \`ProxyAgent\` as \`dispatcher\`) and a global undici dispatcher now applies to OpenAI traffic. Confirm proxies, timeouts and keep-alive still behave.` + : false; +} + +/** tsconfig `lib` without DOM and `@types/node` older than 20: the SDK's fetch types do not resolve. */ +function globalTypesEvidence(context: PackContext): string | false { + const hits: string[] = []; + const nodeTypes = packages(context) + .map(({ json }) => declared(json, '@types/node')) + .find(Boolean); + const nodeTypesOk = nodeTypes ? satisfies(minVersion(nodeTypes) ?? '0.0.0', '>=20.0.0') : false; + for (const { file, text } of files(context)) { + if (!/(?:^|\/)tsconfig[^/]*\.json$/.test(file)) continue; + const config = ts.parseConfigFileTextToJson(file, text).config as + | { compilerOptions?: { lib?: string[] } } + | undefined; + const lib = config?.compilerOptions?.lib; + if (lib && !lib.some((l) => /^dom$/i.test(l)) && !nodeTypesOk) + hits.push( + `\`${file}\` sets lib ${JSON.stringify(lib)} without DOM and @types/node is ${nodeTypes ? JSON.stringify(nodeTypes) : 'not declared'}`, + ); + } + return hits.length + ? `${hits.join('; ')}: openai 5 types use the global fetch classes (Request, Response, Headers); add DOM to lib or @types/node >= 20 (MIGRATION.md, "TypeScript troubleshooting").` + : false; +} + +/** `class X extends OpenAI`: `withOptions()` builds the copy from client options alone. */ +function subclassEvidence(context: PackContext): string | false { + const hits = sources(context).flatMap((f) => { + const source = parse(f.text); + const { classes } = clientClasses(source); + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isClassDeclaration(node)) + for (const clause of node.heritageClauses ?? []) + for (const type of clause.types) + if (ts.isIdentifier(type.expression) && classes.has(type.expression.text)) + found.push(`\`${f.file}\` extends ${type.expression.text}`); + ts.forEachChild(node, visit); + }; + visit(source); + return found; + }); + return hits.length + ? `${hits.join('; ')}: openai 5+ clones clients with \`withOptions()\` (and calls the subclass constructor with client options only), so a constructor that takes other arguments first, or state set outside the options, is lost on a copy. Review the subclass before relying on copies.` + : false; +} + +const note = ( + id: string, + summary: string, + applies: (context: PackContext) => string | false, +): BehaviorNote => ({ id, summary, reported: ['decision'], applies }); + +export const openaiBehavior: BehaviorNote[] = [ + { + id: 'error-headers', + summary: + '`APIError.headers` is a Web `Headers` in openai 5: `error.headers[name]` is undefined; read `error.headers.get(name)`', + reported: ['finding'], + severity: 'breaking', + detect: errorHeaderSites, + }, + { + id: 'web-response', + summary: + '`asResponse()`/`withResponse()` return a Web `Response` in openai 5: node-fetch members (`body.pipe`, `headers.raw()`, `buffer()`) are gone', + reported: ['finding'], + severity: 'breaking', + detect: webResponseSites, + }, + { + id: 'encoded-path-params', + summary: + 'openai 5 URI-encodes path parameters itself: an id passed through `encodeURIComponent` is encoded twice', + reported: ['finding'], + severity: 'breaking', + detect: encodedPathSites, + }, + note('platform-minimums', 'openai 7 requires Node.js 22 and TypeScript 4.9.', platformEvidence), + note( + 'zod-helpers', + 'openai/helpers/zod now throws on optional-only fields, and openai 7 peers on zod ^3.25 || ^4.0.', + zodEvidence, + ), + note( + 'builtin-fetch', + 'openai 5+ uses the built-in fetch: `httpAgent` is gone and a global undici dispatcher applies.', + fetchEvidence, + ), + note( + 'global-types', + 'openai 5 types need the global fetch classes: DOM in lib or @types/node >= 20.', + globalTypesEvidence, + ), + note( + 'client-subclass', + 'openai 5+ copies clients with `withOptions()`, calling the class with client options only.', + subclassEvidence, + ), + { + id: 'also-review', + summary: `Also review the [openai migration guide](${guide}) for the 5.0 changes the compiler cannot see in this repository (pagination internals, CommonJS resource classes), and the 6.0 and 7.0 changelog entries.`, + reported: ['decision'], + }, +]; diff --git a/packages/core/src/packs/openai/detect.ts b/packages/core/src/packs/openai/detect.ts new file mode 100644 index 00000000..d2d59396 --- /dev/null +++ b/packages/core/src/packs/openai/detect.ts @@ -0,0 +1,281 @@ +import { ts } from 'ts-morph'; +import type { SourceSite } from '../contract.js'; +import { + DEL_RESOURCES, + NAMED_PATH_METHODS, + type NamedPathMethod, + REQUEST_OPTION_KEYS, +} from './paths.js'; +import { + chainOf, + clientClasses, + clientNames, + endsWith, + methodCalls, + moduleReferences, + OPENAI_MODULE, + parse, + rootIsClient, + siteOf, + unwrap, + usesOpenai, +} from './syntax.js'; + +/** + * Sites found in the source of a file that uses openai: the 4.x forms the migration guide names, + * written so that a client typed `any` (or a JavaScript file) is seen as well as a typed one. Each + * reads a property chain or an import, never a type, and only in a file that imports openai. + */ +type Detect = (text: string) => SourceSite[]; + +const guarded = + (detect: (source: ts.SourceFile, text: string) => SourceSite[]): Detect => + (text) => + usesOpenai(text) ? detect(parse(text), text) : []; + +/** Resources a client exposes that no other library is likely to share (after the root). */ +export const SDK_RESOURCES = new Set([ + 'beta', + 'chat', + 'responses', + 'vectorStores', + 'fineTuning', + 'evals', + 'containers', + 'conversations', + 'batches', + 'uploads', + 'realtime', + 'skills', + 'videos', + 'admin', +]); + +/** Whether a receiver chain is an SDK resource: named distinctively, or rooted at a client. */ +export function sdkChain(chain: readonly string[], clients: ReadonlySet): boolean { + const rootLength = chain[0] === 'this' ? 2 : 1; + const resource = chain[rootLength]; + if (resource === undefined) return false; + return SDK_RESOURCES.has(resource) || rootIsClient(chain, clients, chain.length - rootLength); +} + +/** + * `client.files.list({ headers })`: request options passed where 5.x reads params. Only a call + * whose one argument is an object literal of request-option keys, on an SDK resource. + */ +export const requestOptionSites = guarded((source) => { + const clients = clientNames(source); + return methodCalls(source).flatMap(({ call, chain }) => { + const only = + call.arguments.length === 1 ? unwrap(call.arguments[0] as ts.Expression) : undefined; + return only && optionsOnly(only, source) && sdkChain(chain, clients) + ? [siteOf(source, only, 'RequestOptions')] + : []; + }); +}); + +/** An object literal whose every key is a request option (and which has at least one). */ +export function optionsOnly(node: ts.Expression, source: ts.SourceFile): boolean { + return ( + ts.isObjectLiteralExpression(node) && + node.properties.length > 0 && + node.properties.every( + (p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + REQUEST_OPTION_KEYS.has(p.name.getText(source)), + ) + ); +} + +/** The 4.x call a named-path method answers to, when `chain` is its resource on a root. */ +export function namedPathMethod( + chain: readonly string[], + method: string, +): NamedPathMethod | undefined { + return NAMED_PATH_METHODS.find( + (m) => m.method === method && chain.length > m.resource.length && endsWith(chain, m.resource), + ); +} + +/** Whether a call still has the 4.x positional shape: the id after the named ones is not params. */ +export function positional(call: ts.CallExpression, entry: NamedPathMethod): boolean { + const ids = entry.keys.length + 1; + const needed = entry.after === 'body' ? ids + 1 : ids; + if (call.arguments.length < needed) return false; + return call.arguments + .slice(0, ids) + .every((a) => !ts.isObjectLiteralExpression(unwrap(a)) && !ts.isSpreadElement(a)); +} + +export const namedPathSites = guarded((source) => + methodCalls(source).flatMap(({ call, method, chain }) => { + const entry = namedPathMethod(chain, method.text); + return entry && positional(call, entry) + ? [siteOf(source, method, `${entry.resource.join('.')}.${method.text}`)] + : []; + }), +); + +/** + * `.del(...)` on a 4.x resource with one path id. A chain that names the resource + * (`client.beta.assistants.del`) is the SDK's; a bare `files.del` or `models.del` only when its + * root holds a client, since other libraries name methods `del` too. Multi-id resources belong to + * `named-path-params`. + */ +export const deleteMethodSites = guarded((source) => { + const clients = clientNames(source); + return methodCalls(source).flatMap(({ method, chain }) => { + if (method.text !== 'del' || namedPathMethod(chain, 'del')) return []; + const resource = delResource(chain, clients); + return resource ? [siteOf(source, method, `${resource.join('.')}.del`)] : []; + }); +}); + +/** The 4.x resource a `.del` receiver chain names, when the chain alone says it is the SDK's. */ +export function delResource( + chain: readonly string[], + clients: ReadonlySet, +): readonly string[] | undefined { + const resource = DEL_RESOURCES.find((r) => chain.length > r.length && endsWith(chain, r)); + if (!resource) return undefined; + const named = resource.length > 1 || resource[0] === 'vectorStores'; + return named || rootIsClient(chain, clients, resource.length) ? resource : undefined; +} + +const BETA_CHAT = new Set(['parse', 'stream', 'runTools']); + +/** `X.beta.chat.completions.parse|stream|runTools`: the node `X.beta`. */ +export function betaChatNodes(source: ts.SourceFile): ts.PropertyAccessExpression[] { + const found: ts.PropertyAccessExpression[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAccessExpression(node) && + node.name.text === 'beta' && + ts.isPropertyAccessExpression(node.parent) && + node.parent.name.text === 'chat' && + ts.isPropertyAccessExpression(node.parent.parent) && + node.parent.parent.name.text === 'completions' && + ts.isPropertyAccessExpression(node.parent.parent.parent) && + BETA_CHAT.has(node.parent.parent.parent.name.text) + ) + found.push(node); + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + +export const betaChatSites = guarded((source) => + betaChatNodes(source).map((node) => siteOf(source, node.name, 'beta.chat.completions')), +); + +/** `.runFunctions(...)` on chat completions, beta or not. */ +export const runFunctionsSites = guarded((source) => + methodCalls(source).flatMap(({ method, chain }) => + method.text === 'runFunctions' && endsWith(chain, ['chat', 'completions']) + ? [siteOf(source, method, 'chat.completions.runFunctions')] + : [], + ), +); + +export const RUNNER_RENAMES: Record = { + functionCall: 'functionToolCall', + functionCallResult: 'functionToolCallResult', + finalFunctionCall: 'finalFunctionToolCall', + finalFunctionCallResult: 'finalFunctionToolCallResult', +}; +const RUNNER_EVENT_METHODS = new Set(['on', 'once', 'off', 'emitted']); +const RUNNER_METHODS = new Set(['finalFunctionCall', 'finalFunctionCallResult']); + +/** Runner event names in `.on('functionCall', …)` and the `finalFunctionCall()` methods. */ +export function runnerNodes(source: ts.SourceFile): (ts.StringLiteralLike | ts.Identifier)[] { + return methodCalls(source).flatMap( + ({ call, method }): (ts.StringLiteralLike | ts.Identifier)[] => { + if (RUNNER_METHODS.has(method.text) && call.arguments.length === 0) return [method]; + const first = call.arguments[0]; + return RUNNER_EVENT_METHODS.has(method.text) && + first && + ts.isStringLiteralLike(first) && + RUNNER_RENAMES[first.text] !== undefined + ? [first] + : []; + }, + ); +} + +export const runnerSites = guarded((source) => + runnerNodes(source).map((node) => siteOf(source, node, node.text)), +); + +/** `page.nextPageParams()` / `page.nextPageInfo()`: gone, `nextPageRequestOptions()` replaces both. */ +export const pageHelperSites = guarded((source) => + methodCalls(source).flatMap(({ method }) => + method.text === 'nextPageParams' || method.text === 'nextPageInfo' + ? [siteOf(source, method, method.text)] + : [], + ), +); + +/** `fileFromPath` imported from openai, or read off the client class (`OpenAI.fileFromPath`). */ +export const fileFromPathSites = guarded((source) => { + const { classes, namespaces } = clientClasses(source); + const fromOpenai = (node: ts.Node): boolean => { + let current: ts.Node | undefined = node; + while (current && !ts.isImportDeclaration(current) && !ts.isVariableDeclaration(current)) + current = current.parent; + if (current && ts.isImportDeclaration(current)) + return ( + ts.isStringLiteral(current.moduleSpecifier) && + OPENAI_MODULE.test(current.moduleSpecifier.text) + ); + const init = current?.initializer && unwrap(current.initializer); + return ( + !!init && + ts.isCallExpression(init) && + ts.isStringLiteralLike(init.arguments[0] as ts.Expression) && + OPENAI_MODULE.test((init.arguments[0] as ts.StringLiteralLike).text) + ); + }; + const sites: SourceSite[] = []; + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && node.text === 'fileFromPath') { + const parent = node.parent; + const imported = + (ts.isImportSpecifier(parent) || ts.isBindingElement(parent)) && + (parent.propertyName ?? parent.name) === node && + fromOpenai(parent); + const owner = + ts.isPropertyAccessExpression(parent) && parent.name === node + ? chainOf(parent.expression) + : undefined; + const member = + owner !== undefined && + (owner.length === 1 + ? classes.has(owner[0] as string) || namespaces.has(owner[0] as string) + : owner.length === 2 && namespaces.has(owner[0] as string)); + if (imported || member) sites.push(siteOf(source, node, 'fileFromPath')); + } + ts.forEachChild(node, visit); + }; + visit(source); + return sites; +}); + +/** Entry points 5.x removed: `openai/shims/*`, `openai/_shims/*`, `openai/src`, `openai/core`, beta chat types. */ +export const REMOVED_MODULE = + /^openai\/(?:_?shims(?:\/.*)?|src(?:\/.*)?|core|resources\/beta\/chat(?:\/completions(?:\/index)?|\/index)?(?:\.js)?)$/; + +export const removedModuleSites = guarded((source) => + moduleReferences(source) + .filter((m) => REMOVED_MODULE.test(m.specifier)) + .map((m) => siteOf(source, m.node, m.specifier)), +); + +/** Public files 5.x moved under `core/`; the old paths still re-export them, deprecated. */ +export const CORE_MOVED = /^openai\/(error|pagination|resource|streaming|uploads)(?:\.js|\.mjs)?$/; + +export const corePathSites = guarded((source) => + moduleReferences(source) + .filter((m) => CORE_MOVED.test(m.specifier)) + .map((m) => siteOf(source, m.node, m.specifier)), +); diff --git a/packages/core/src/packs/openai/fixtures/beta-chat/after.ts b/packages/core/src/packs/openai/fixtures/beta-chat/after.ts new file mode 100644 index 00000000..7c139498 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/beta-chat/after.ts @@ -0,0 +1,30 @@ +import OpenAI from 'openai'; +import { zodResponseFormat } from 'openai/helpers/zod'; +import { z } from 'zod'; + +const openai = new OpenAI(); +const Answer = z.object({ answer: z.string() }); +declare const tools: OpenAI.ChatCompletionTool[]; +declare const functions: never[]; + +export const parsed = await openai.chat.completions.parse({ // @uptide beta-chat at:beta + model: 'gpt-4o-2024-08-06', + messages: [{ role: 'user', content: 'What is 2 + 2?' }], + response_format: zodResponseFormat(Answer, 'answer'), +}); +export const stream = openai.chat.completions // @uptide beta-chat at:beta + .stream({ model: 'gpt-4o-mini', messages: [], stream: true }); + +const runner = (openai as any).chat.completions.runTools({ model: 'gpt-4o', messages: [], tools }); // @uptide beta-chat at:beta +runner.on('functionToolCall', (call: unknown) => console.log(call)); // @uptide runner-renames at:'functionCall' +runner.on("functionToolCallResult", (result: string) => console.log(result)); // @uptide runner-renames at:"functionCallResult" +export const last = await runner.finalFunctionToolCall(); // @uptide runner-renames at:finalFunctionCall +export const lastResult = await runner.finalFunctionToolCallResult(); // @uptide runner-renames at:finalFunctionCallResult + +export const legacy = openai.beta.chat.completions.runFunctions({ model: 'gpt-4o', messages: [], functions }); // @uptide run-functions at:runFunctions + +// Already 5.x, or not the runner: left alone. +runner.on('functionToolCall', (call: unknown) => console.log(call)); // @uptide runner-renames keep at:'functionToolCall' +process.on('message', () => undefined); // @uptide runner-renames keep at:'message' +export const assistant = await openai.beta.assistants.create({ model: 'gpt-4o' }); // @uptide beta-chat keep at:beta +export const plain = await openai.chat.completions.create({ model: 'gpt-4o', messages: [] }); // @uptide run-functions keep at:create diff --git a/packages/core/src/packs/openai/fixtures/beta-chat/before.ts b/packages/core/src/packs/openai/fixtures/beta-chat/before.ts new file mode 100644 index 00000000..5e52b2c6 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/beta-chat/before.ts @@ -0,0 +1,30 @@ +import OpenAI from 'openai'; +import { zodResponseFormat } from 'openai/helpers/zod'; +import { z } from 'zod'; + +const openai = new OpenAI(); +const Answer = z.object({ answer: z.string() }); +declare const tools: OpenAI.ChatCompletionTool[]; +declare const functions: never[]; + +export const parsed = await openai.beta.chat.completions.parse({ // @uptide beta-chat at:beta + model: 'gpt-4o-2024-08-06', + messages: [{ role: 'user', content: 'What is 2 + 2?' }], + response_format: zodResponseFormat(Answer, 'answer'), +}); +export const stream = openai.beta.chat.completions // @uptide beta-chat at:beta + .stream({ model: 'gpt-4o-mini', messages: [], stream: true }); + +const runner = (openai as any).beta.chat.completions.runTools({ model: 'gpt-4o', messages: [], tools }); // @uptide beta-chat at:beta +runner.on('functionCall', (call: unknown) => console.log(call)); // @uptide runner-renames at:'functionCall' +runner.on("functionCallResult", (result: string) => console.log(result)); // @uptide runner-renames at:"functionCallResult" +export const last = await runner.finalFunctionCall(); // @uptide runner-renames at:finalFunctionCall +export const lastResult = await runner.finalFunctionCallResult(); // @uptide runner-renames at:finalFunctionCallResult + +export const legacy = openai.beta.chat.completions.runFunctions({ model: 'gpt-4o', messages: [], functions }); // @uptide run-functions at:runFunctions + +// Already 5.x, or not the runner: left alone. +runner.on('functionToolCall', (call: unknown) => console.log(call)); // @uptide runner-renames keep at:'functionToolCall' +process.on('message', () => undefined); // @uptide runner-renames keep at:'message' +export const assistant = await openai.beta.assistants.create({ model: 'gpt-4o' }); // @uptide beta-chat keep at:beta +export const plain = await openai.chat.completions.create({ model: 'gpt-4o', messages: [] }); // @uptide run-functions keep at:create diff --git a/packages/core/src/packs/openai/fixtures/blind-spots/before.ts b/packages/core/src/packs/openai/fixtures/blind-spots/before.ts new file mode 100644 index 00000000..feccf43c --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/blind-spots/before.ts @@ -0,0 +1,42 @@ +import OpenAI, { fileFromPath } from 'openai'; // @uptide file-from-path at:fileFromPath + +const openai = new OpenAI(); +declare const jobs: any; + +// Typed `any` or `unknown`: the compiler cannot see these 4.x reads. +export async function retryAfter(prompt: string): Promise { + try { + await openai.chat.completions.create({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }); + } catch (e: any) { + const wait = e.headers['retry-after']; // @uptide error-headers at:headers + const id = e.headers?.['x-request-id']; // @uptide error-headers at:headers + return wait ?? id; + } + return undefined; +} + +export async function download(id: string) { + const res = await openai.files.content(id).asResponse(); + res.body.pipe(process.stdout); // @uptide web-response at:pipe + const { response } = await openai.models.list().withResponse(); + return response.headers.raw(); // @uptide web-response at:raw +} + +export const file = await openai.files.retrieve(encodeURIComponent('file/1')); // @uptide encoded-path-params at:encodeURIComponent +export const upload = await openai.files.create({ file: await OpenAI.fileFromPath('data.jsonl'), purpose: 'batch' }); // @uptide file-from-path at:fileFromPath +export const next = jobs.nextPageParams(); // @uptide page-helpers at:nextPageParams + +// Read the 5.x way, or not the SDK's: left alone. +export async function handled() { + try { + await openai.models.list(); + } catch (e: any) { + return e.headers.get('retry-after'); + } + const request = new Request('https://example.com'); + return request.headers.get('x'); +} +export const search = await fetch(`https://example.com/${encodeURIComponent('a b')}`); +declare const helpers: { fileFromPath(path: string): Promise }; +export const local = await helpers.fileFromPath('data.jsonl'); // @uptide file-from-path keep at:fileFromPath +export const following = await jobs.getNextPage(); // @uptide page-helpers keep at:getNextPage diff --git a/packages/core/src/packs/openai/fixtures/delete-method/after.ts b/packages/core/src/packs/openai/fixtures/delete-method/after.ts new file mode 100644 index 00000000..f5b98bd2 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/delete-method/after.ts @@ -0,0 +1,23 @@ +import OpenAI from 'openai'; + +const openai = new OpenAI(); +declare const assistantId: string; +declare const cache: { files: { del(key: string): void } }; +declare const redis: { del(key: string): Promise }; + +export const assistant = await openai.beta.assistants.delete(assistantId); // @uptide delete-method at:del +export const file = await openai.files.delete('file_1'); // @uptide delete-method at:del +export const store = await openai.vectorStores.delete('vs_1', { timeout: 1000 }); // @uptide delete-method at:del +export const completion = await openai.chat.completions.delete('chatcmpl_1'); // @uptide delete-method at:del + +class Uploader { + constructor(private client: OpenAI) {} + remove(id: string) { + return this.client.files.delete(id); // @uptide delete-method at:del + } +} +export { Uploader }; + +// Not the SDK: a `files.del` on something that is not a client, and another library's `del`. +cache.files.del('key'); // @uptide delete-method keep at:del +await redis.del('key'); // @uptide delete-method keep at:del diff --git a/packages/core/src/packs/openai/fixtures/delete-method/before.ts b/packages/core/src/packs/openai/fixtures/delete-method/before.ts new file mode 100644 index 00000000..a7dd60f7 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/delete-method/before.ts @@ -0,0 +1,23 @@ +import OpenAI from 'openai'; + +const openai = new OpenAI(); +declare const assistantId: string; +declare const cache: { files: { del(key: string): void } }; +declare const redis: { del(key: string): Promise }; + +export const assistant = await openai.beta.assistants.del(assistantId); // @uptide delete-method at:del +export const file = await openai.files.del('file_1'); // @uptide delete-method at:del +export const store = await openai.vectorStores.del('vs_1', { timeout: 1000 }); // @uptide delete-method at:del +export const completion = await openai.chat.completions.del('chatcmpl_1'); // @uptide delete-method at:del + +class Uploader { + constructor(private client: OpenAI) {} + remove(id: string) { + return this.client.files.del(id); // @uptide delete-method at:del + } +} +export { Uploader }; + +// Not the SDK: a `files.del` on something that is not a client, and another library's `del`. +cache.files.del('key'); // @uptide delete-method keep at:del +await redis.del('key'); // @uptide delete-method keep at:del diff --git a/packages/core/src/packs/openai/fixtures/modules/after.ts b/packages/core/src/packs/openai/fixtures/modules/after.ts new file mode 100644 index 00000000..ac04baee --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/modules/after.ts @@ -0,0 +1,11 @@ +import OpenAI from 'openai'; // @uptide removed-modules at:'openai/src' +import type { ParsedChatCompletion } from 'openai/resources/chat/completions'; // @uptide removed-modules at:'openai/resources/beta/chat/completions' +import { APIError, RateLimitError } from 'openai/core/error'; // @uptide core-paths at:'openai/error' +import { toFile, type Uploadable } from "openai/core/uploads"; // @uptide core-paths at:"openai/uploads" +import { CursorPage } from 'openai/core/pagination'; // @uptide core-paths keep at:'openai/core/pagination' + +import { OpenAI as Client } from 'openai'; // @uptide removed-modules keep at:'openai' + +export const client = new OpenAI(); +export type Parsed = ParsedChatCompletion<{ answer: string }>; +export { APIError, Client, CursorPage, RateLimitError, toFile, type Uploadable }; diff --git a/packages/core/src/packs/openai/fixtures/modules/before.ts b/packages/core/src/packs/openai/fixtures/modules/before.ts new file mode 100644 index 00000000..76291419 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/modules/before.ts @@ -0,0 +1,12 @@ +import 'openai/shims/node'; // @uptide removed-modules at:'openai/shims/node' +import OpenAI from 'openai/src'; // @uptide removed-modules at:'openai/src' +import type { ParsedChatCompletion } from 'openai/resources/beta/chat/completions'; // @uptide removed-modules at:'openai/resources/beta/chat/completions' +import { APIError, RateLimitError } from 'openai/error'; // @uptide core-paths at:'openai/error' +import { toFile, type Uploadable } from "openai/uploads"; // @uptide core-paths at:"openai/uploads" +import { CursorPage } from 'openai/core/pagination'; // @uptide core-paths keep at:'openai/core/pagination' + +import { OpenAI as Client } from 'openai'; // @uptide removed-modules keep at:'openai' + +export const client = new OpenAI(); +export type Parsed = ParsedChatCompletion<{ answer: string }>; +export { APIError, Client, CursorPage, RateLimitError, toFile, type Uploadable }; diff --git a/packages/core/src/packs/openai/fixtures/named-path-params/after.ts b/packages/core/src/packs/openai/fixtures/named-path-params/after.ts new file mode 100644 index 00000000..62cbd0db --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/named-path-params/after.ts @@ -0,0 +1,29 @@ +import OpenAI from 'openai'; + +const client = new OpenAI(); +declare const threadId: string; +declare const runId: string; +declare const thread_id: string; +declare const outputs: { tool_call_id: string; output: string }[]; + +export const run = await client.beta.threads.runs.retrieve(runId, { thread_id: threadId }); // @uptide named-path-params at:retrieve +export const cancelled = await client.beta.threads.runs.cancel(runId, { thread_id: threadId }, { timeout: 5000 }); // @uptide named-path-params at:cancel +export const submitted = await client.beta.threads.runs.submitToolOutputs(runId, { thread_id: threadId, tool_outputs: outputs }); // @uptide named-path-params at:submitToolOutputs +export const polled = await client.beta.threads.runs.submitToolOutputsAndPoll( // @uptide named-path-params at:submitToolOutputsAndPoll + runId, + { + thread_id: threadId, + tool_outputs: outputs, + }, +); +export const message = await client.beta.threads.messages.retrieve('msg_1', { thread_id }); // @uptide named-path-params at:retrieve +export const step = await client.beta.threads.runs.steps.retrieve('step_1', { thread_id: threadId, run_id: runId }); // @uptide named-path-params at:retrieve +export const file = await client.vectorStores.files.retrieve('file_1', { vector_store_id: 'vs_1' }); // @uptide named-path-params at:retrieve +export const removed = await client.vectorStores.files.delete('file_1', { vector_store_id: 'vs_1' }); // @uptide named-path-params at:del +export const item = await client.evals.runs.outputItems.retrieve('item_1', { eval_id: 'eval_1', run_id: 'run_1' }); // @uptide named-path-params at:retrieve + +// Already in the 5.x shape, and methods whose single id did not change: left alone. +export const again = await client.beta.threads.runs.retrieve(runId, { thread_id: threadId }); // @uptide named-path-params keep at:retrieve +export const listed = client.beta.threads.runs.list(threadId); +export const created = await client.beta.threads.runs.create(threadId, { assistant_id: 'asst_1' }); +export const fileOnly = await client.files.retrieve('file_1'); diff --git a/packages/core/src/packs/openai/fixtures/named-path-params/before.ts b/packages/core/src/packs/openai/fixtures/named-path-params/before.ts new file mode 100644 index 00000000..241663e6 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/named-path-params/before.ts @@ -0,0 +1,29 @@ +import OpenAI from 'openai'; + +const client = new OpenAI(); +declare const threadId: string; +declare const runId: string; +declare const thread_id: string; +declare const outputs: { tool_call_id: string; output: string }[]; + +export const run = await client.beta.threads.runs.retrieve(threadId, runId); // @uptide named-path-params at:retrieve +export const cancelled = await client.beta.threads.runs.cancel(threadId, runId, { timeout: 5000 }); // @uptide named-path-params at:cancel +export const submitted = await client.beta.threads.runs.submitToolOutputs(threadId, runId, { tool_outputs: outputs }); // @uptide named-path-params at:submitToolOutputs +export const polled = await client.beta.threads.runs.submitToolOutputsAndPoll( // @uptide named-path-params at:submitToolOutputsAndPoll + threadId, + runId, + { + tool_outputs: outputs, + }, +); +export const message = await client.beta.threads.messages.retrieve(thread_id, 'msg_1'); // @uptide named-path-params at:retrieve +export const step = await client.beta.threads.runs.steps.retrieve(threadId, runId, 'step_1'); // @uptide named-path-params at:retrieve +export const file = await client.vectorStores.files.retrieve('vs_1', 'file_1'); // @uptide named-path-params at:retrieve +export const removed = await client.vectorStores.files.del('vs_1', 'file_1'); // @uptide named-path-params at:del +export const item = await client.evals.runs.outputItems.retrieve('eval_1', 'run_1', 'item_1'); // @uptide named-path-params at:retrieve + +// Already in the 5.x shape, and methods whose single id did not change: left alone. +export const again = await client.beta.threads.runs.retrieve(runId, { thread_id: threadId }); // @uptide named-path-params keep at:retrieve +export const listed = client.beta.threads.runs.list(threadId); +export const created = await client.beta.threads.runs.create(threadId, { assistant_id: 'asst_1' }); +export const fileOnly = await client.files.retrieve('file_1'); diff --git a/packages/core/src/packs/openai/fixtures/request-options/after.ts b/packages/core/src/packs/openai/fixtures/request-options/after.ts new file mode 100644 index 00000000..68c3f0c0 --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/request-options/after.ts @@ -0,0 +1,13 @@ +import OpenAI from 'openai'; + +const client = new OpenAI(); +declare const traceId: string; + +export const files = await client.files.list({}, { headers: { 'x-trace-id': traceId } }); // @uptide request-options at:{ +export const batches = client.batches.list({}, { timeout: 10_000, maxRetries: 1 }); // @uptide request-options at:{ + +// Params, an explicit `{}` first, or not the SDK: left alone. +export const recent = await client.files.list({ purpose: 'batch', headers: {} }); // @uptide request-options keep at:{ +export const explicit = await client.files.list({}, { headers: { 'x-trace-id': traceId } }); +export const other = await fetchLater({ timeout: 1000 }); +declare function fetchLater(options: { timeout: number }): Promise; diff --git a/packages/core/src/packs/openai/fixtures/request-options/before.ts b/packages/core/src/packs/openai/fixtures/request-options/before.ts new file mode 100644 index 00000000..3b273a2e --- /dev/null +++ b/packages/core/src/packs/openai/fixtures/request-options/before.ts @@ -0,0 +1,13 @@ +import OpenAI from 'openai'; + +const client = new OpenAI(); +declare const traceId: string; + +export const files = await client.files.list({ headers: { 'x-trace-id': traceId } }); // @uptide request-options at:{ +export const batches = client.batches.list({ timeout: 10_000, maxRetries: 1 }); // @uptide request-options at:{ + +// Params, an explicit `{}` first, or not the SDK: left alone. +export const recent = await client.files.list({ purpose: 'batch', headers: {} }); // @uptide request-options keep at:{ +export const explicit = await client.files.list({}, { headers: { 'x-trace-id': traceId } }); +export const other = await fetchLater({ timeout: 1000 }); +declare function fetchLater(options: { timeout: number }): Promise; diff --git a/packages/core/src/packs/openai/ground-truth.json b/packages/core/src/packs/openai/ground-truth.json new file mode 100644 index 00000000..b02650af --- /dev/null +++ b/packages/core/src/packs/openai/ground-truth.json @@ -0,0 +1,202 @@ +{ + "$comment": "Ground truth for the openai pack (docs/packs.md, \"Ground truth\"). Every public repository below merged an openai 4.x to 7.x upgrade and is pinned at the commit before it; found by mining npm manifests that moved openai from ^4 to ^7 and by searching for merged 4.104.0 to 7.x pull requests (RESEARCH.md lists the ones screened out). Expected findings were drafted by pnpm packs:truth and read by hand before the pack was scored: the diagnostics the repository's own TypeScript reports with the target (and the companions its upgrade moved, --also) linked in and not without them, and the lines the upgrade changed for openai (one site per change run, at its first line of code; comments, insertions and other packages' changes such as zod 4 and @openai/agents left out). A site no rule of the pack claims is `generic`. Each `with` lists what the upgrade moved because of openai (from its lockfile).", + "package": "openai", + "repos": [ + { + "repo": "zeikar/charivo", + "commit": "b9a476d9acb59f473e08f0e97ce6e91dc2201d99", + "migration": "https://github.com/zeikar/charivo/compare/b9a476d9acb59f473e08f0e97ce6e91dc2201d99...67ae240f175f166ca0d7c5e79c1ea160e09ef133", + "from": "4.104.0", + "to": "7.10.0", + "why": "pnpm workspace: Chat Completions tool calls read off ChatCompletionMessageToolCall, which became a function | custom union (packages/llm); audio speech and transcription (packages/stt, packages/tts) compile unchanged", + "findings": [ + { + "file": "packages/llm/src/openai-tool-format.ts", + "line": 119, + "rule": "tool-call-union", + "note": "compiler TS2339, changed by the upgrade (narrowed on type)" + }, + { + "file": "packages/llm/src/openai-tool-format.ts", + "line": 129, + "rule": "tool-call-union", + "note": "compiler TS18046, changed by the upgrade" + }, + { + "file": "packages/llm/src/openai-tool-format.ts", + "line": 135, + "rule": "tool-call-union", + "note": "changed by the upgrade: function.arguments is no longer known to be a string off the union" + } + ] + }, + { + "repo": "openai/openai-guardrails-js", + "commit": "6c13ba08ab021215fd6f8c900ae15ae29627f2d2", + "migration": "https://github.com/openai/openai-guardrails-js/pull/122", + "from": "4.104.0", + "to": "7.9.0", + "with": { + "@openai/agents": "0.17.2", + "zod": "4.5.4" + }, + "why": "library that subclasses OpenAI and AzureOpenAI: client options typing, client cloning through withOptions, credentials copied by hand", + "findings": [ + { + "file": "src/base-client.ts", + "line": 368, + "rule": "generic", + "note": "compiler TS2345: AzureOpenAI no longer accepts the full ClientOptions" + }, + { + "file": "src/client.ts", + "line": 9, + "rule": "generic", + "note": "changed by the upgrade: imports ClientOptions types for withOptions overrides" + }, + { + "file": "src/client.ts", + "line": 31, + "rule": "generic", + "note": "changed by the upgrade: subclass state is assigned after SDK cloning" + }, + { + "file": "src/client.ts", + "line": 44, + "rule": "generic", + "note": "changed by the upgrade: constructor takes client options first, as withOptions calls it" + }, + { + "file": "src/client.ts", + "line": 51, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 81, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 121, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 134, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 141, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 171, + "rule": "generic", + "note": "changed by the upgrade" + }, + { + "file": "src/client.ts", + "line": 214, + "rule": "generic", + "note": "changed by the upgrade: a second client copied apiKey/baseURL by hand; withOptions({}) keeps provider and callback credentials" + } + ] + }, + { + "repo": "wardby/wardby", + "commit": "d46ec64dc0be781aa43967d9b6d26be9386aa67d", + "migration": "https://github.com/wardby/wardby/pull/231", + "from": "4.104.0", + "to": "7.30.0", + "why": "Responses API streaming provider; compiles unchanged at 7.x, the upgrade adopted the SDK's own usage and reasoning types", + "findings": [ + { + "file": "src/providers/llm/openai.ts", + "line": 107, + "rule": "generic", + "note": "changed by the upgrade: a local ResponsesUsage interface replaced by OpenAI.Responses.ResponseUsage (optional)" + }, + { + "file": "src/providers/llm/openai.ts", + "line": 188, + "rule": "generic", + "note": "changed by the upgrade: a ReasoningEffort cast dropped, 7.x types the values (optional)" + } + ] + }, + { + "repo": "csnyder256/ux-struggle-detector", + "commit": "b73084896208f7d2794b84bf23de3157eca5fc09", + "migration": "https://github.com/csnyder256/ux-struggle-detector/pull/11", + "from": "4.104.0", + "to": "7.23.0", + "why": "Next.js app with one Chat Completions provider; compiles unchanged at 7.x", + "findings": [ + { + "file": "src/lib/providers/openai.ts", + "line": 24, + "rule": "generic", + "note": "changed by the upgrade: max_tokens to max_completion_tokens, a request change; the declaration is the same in 4.104.0 and 7.x" + }, + { + "file": "src/lib/providers/openai.ts", + "line": 66, + "rule": "generic", + "note": "changed by the upgrade, as above" + } + ] + }, + { + "repo": "grantsingleton/batch-ai", + "commit": "05c7e9f132b3e6ec6504de3d82371889fdc2d368", + "migration": "https://github.com/grantsingleton/batch-ai/pull/14", + "from": "4.83.0", + "to": "7.4.0", + "with": { + "zod": "4.4.3" + }, + "why": "library over the Batch API with zodResponseFormat; zod 3.24 is below openai 7's zod peer range; compiles unchanged at 7.x", + "findings": [ + { + "file": "src/index.ts", + "line": 32, + "rule": "generic", + "note": "changed by the upgrade: the ChatModel parameter also accepts any string (optional)" + }, + { + "file": "src/index.ts", + "line": 35, + "rule": "generic", + "note": "changed by the upgrade, as above" + } + ] + }, + { + "repo": "digitalchokro/askchokro", + "commit": "047fc4485538a3bff4224a9a604d83143c2cd5d2", + "migration": "https://github.com/digitalchokro/askchokro/pull/31", + "from": "4.104.0", + "to": "7.4.0", + "why": "pnpm workspace with OpenAI and Groq (OpenAI-compatible) providers; a version bump only, compiles unchanged", + "findings": [] + }, + { + "repo": "microsoft/prompty", + "commit": "e878b6b7789ff621265ca086b20d4e2d70914f81", + "directory": "runtime/typescript", + "migration": "https://github.com/microsoft/prompty/pull/527", + "from": "4.104.0", + "to": "7.10.0", + "why": "npm workspace: chat, responses, embeddings and images executors; a grouped version bump, compiles unchanged", + "findings": [] + } + ] +} diff --git a/packages/core/src/packs/openai/index.ts b/packages/core/src/packs/openai/index.ts new file mode 100644 index 00000000..763ccfc5 --- /dev/null +++ b/packages/core/src/packs/openai/index.ts @@ -0,0 +1,259 @@ +import { definePack } from '../contract.js'; +import { openaiBehavior } from './behavior.js'; +import { + betaChatSites, + corePathSites, + deleteMethodSites, + fileFromPathSites, + namedPathSites, + pageHelperSites, + removedModuleSites, + requestOptionSites, + runFunctionsSites, + runnerSites, +} from './detect.js'; +import { + promoteBetaChat, + renameDel, + renameRunner, + rewriteCorePath, + rewriteNamedPathParams, + rewriteRemovedModule, + SDK_DEL, + separateRequestOptions, +} from './rewrite.js'; + +const repo = 'https://github.com/openai/openai-node'; +const migration = `${repo}/blob/v7.32.0/MIGRATION.md`; +const changelog = `${repo}/blob/v7.32.0/CHANGELOG.md`; + +/** + * openai 4 → 7, in one pack: sources pinned at 7.32.0 and read 2026-10-14 (RESEARCH.md, with + * every change classified). 5.0 carries the API changes (MIGRATION.md); 5.12.1 made tool calls + * a function | custom union; 6.0's one breaking change widens function-call outputs; 7.0's is the + * Node 22 floor. Rewrites are made only where they are the same request whatever the code around + * them is; model names, prompts and parameter values are never touched. + * + * No release group: the openai-node repository publishes `openai` alone, and `@openai/agents` + * (which depends on it) is versioned on its own line. `requiresBuild`: 7.x exports explicit + * subpaths only, so a deep import TypeScript still resolves from disk (`moduleResolution: node`) + * can fail in Node or a bundler, and only the build shows it. + */ +export const openaiPack = definePack({ + meta: { + package: 'openai', + from: '>=4 <5', + to: '>=7 <8', + sources: [ + { title: 'openai-node migration guide, v7.32.0 (read 2026-10-14)', url: migration }, + { + title: 'openai-node migration guide as released with 5.0.0', + url: `${repo}/blob/v5.0.0/MIGRATION.md`, + }, + { title: 'openai-node changelog, 5.0.0 to 7.32.0 (read 2026-10-14)', url: changelog }, + { + title: 'openai 5.12.1: tool calls become a function | custom union', + url: `${repo}/releases/tag/v5.12.1`, + }, + { title: 'openai 6.0.0: function call outputs widen', url: `${repo}/releases/tag/v6.0.0` }, + { title: 'openai 7.0.0: Node.js 22 required', url: `${repo}/releases/tag/v7.0.0` }, + ], + maintainer: 'uptide-dev', + }, + defaultTarget: '7.32.0', + requiresBuild: true, + inventory: [ + 'package.json', + '{.nvmrc,.node-version}', + '.github/workflows/*.{yml,yaml}', + 'tsconfig*.json', + '*.{ts,mts,cts,js,mjs,cjs}', + '{src,app,lib,server,api,pages,utils,services,scripts}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', + ], + rules: [ + { + id: 'named-path-params', + summary: + 'methods with several path ids take the last one positionally and the others as named params', + severity: 'breaking', + kinds: ['signature', 'type', 'removed', 'required'], + symbols: + /(?:VectorStores\.Files#(?:retrieve|update|del|delete|content)|VectorStores\.FileBatches#(?:retrieve|cancel|listFiles)|Threads\.Runs#(?:retrieve|update|cancel|poll|submitToolOutputs\w*)|Runs\.Steps#(?:retrieve|list)|Threads\.Messages#(?:retrieve|update|del|delete)|Checkpoints\.Permissions#(?:del|delete)|Evals\.Runs#(?:retrieve|del|delete|cancel)|Runs\.OutputItems#(?:retrieve|list)|Containers\.Files#(?:retrieve|del|delete)|Files\.Content#retrieve)$|^TS(?:2345|2554|2769)$/, + message: + /(?:^$|(?:Run|Step|Message|File|FileBatch|Permission|OutputItem|Content)(?:Retrieve|Update|Delete|Cancel|Content|ListFiles|List|SubmitToolOutputs\w*)Params)/, + detect: namedPathSites, + rewrite: rewriteNamedPathParams, + guide: + 'openai 5 names every path id but the last: `client.beta.threads.runs.retrieve(threadId, runId)` is `client.beta.threads.runs.retrieve(runId, { thread_id: threadId })`; a body merges into the same object (`submitToolOutputs(runId, { thread_id: threadId, tool_outputs })`), and request options stay the last argument. Keys: vector store files and file batches `vector_store_id`; runs, run steps and messages `thread_id` (steps also `run_id`); eval runs `eval_id` (output items also `run_id`); container files `container_id`; checkpoint permissions `fine_tuned_model_checkpoint`. `runs.poll`, `submitToolOutputsAndPoll` and `submitToolOutputsStream` changed the same way. Keep every id and value as it is.', + }, + { + id: 'delete-method', + summary: '`.del()` is `.delete()` on every resource', + severity: 'breaking', + kinds: ['removed', 'type', 'renamed'], + symbols: /#del$|^TS(?:2339|2551)$/, + message: new RegExp(`(?:^$|${SDK_DEL.source})`), + detect: deleteMethodSites, + rewrite: renameDel, + guide: + '`.del(id)` is `.delete(id)` with the same arguments. Where the resource has several path ids (vector store files, thread messages, eval runs, container files, checkpoint permissions), the ids are also reshaped as in named-path-params.', + }, + { + id: 'beta-chat', + summary: '`beta.chat.completions` (parse, stream, runTools) moved to `chat.completions`', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /(?:^|[.#])Beta#chat$|^TS2339$/, + message: /(?:^$|Property 'chat' does not exist on type 'Beta')/, + detect: betaChatSites, + rewrite: promoteBetaChat, + guide: + '`client.beta.chat.completions.parse|stream|runTools` is `client.chat.completions.parse|stream|runTools`: the same methods and arguments, promoted out of beta.', + }, + { + id: 'runner-renames', + summary: + 'runTools runner events and methods are renamed: `functionCall` → `functionToolCall`, `finalFunctionCall()` → `finalFunctionToolCall()`', + severity: 'breaking', + kinds: ['removed', 'type', 'signature', 'renamed'], + symbols: /#finalFunctionCall(?:Result)?$|^TS(?:2345|2339|2551|2769)$/, + message: /(?:^$|['"](?:final)?[fF]unctionCall(?:Result)?['"])/, + detect: runnerSites, + rewrite: renameRunner, + guide: + "Rename the runner's events `functionCall`, `functionCallResult`, `finalFunctionCall`, `finalFunctionCallResult` to `functionToolCall`, `functionToolCallResult`, `finalFunctionToolCall`, `finalFunctionToolCallResult`, and the methods `finalFunctionCall()` / `finalFunctionCallResult()` to `finalFunctionToolCall()` / `finalFunctionToolCallResult()`. The payloads keep their shape.", + }, + { + id: 'run-functions', + summary: '`runFunctions()` is removed: use `runTools()`', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /#runFunctions$|^TS(?:2339|2551)$/, + message: /(?:^$|'runFunctions')/, + detect: runFunctionsSites, + guide: + '`chat.completions.runFunctions({ functions: [{ function, parse, description }] })` is `chat.completions.runTools({ tools: [{ type: "function", function: { function, parse, description } }] })` (wrap each function, keep its name, parameters and parse), and a forced `function_call: { name }` is `tool_choice: { type: "function", function: { name } }`. Rename the runner events as in runner-renames. Keep the model, messages and every other parameter.', + }, + { + id: 'removed-modules', + summary: + '`openai/shims/*`, `openai/src`, `openai/core` and `openai/resources/beta/chat/completions` are gone', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /^TS(?:2307|2792)$/, + message: /'openai\/(?:_?shims|src|core'|resources\/beta\/chat)/, + detect: removedModuleSites, + rewrite: rewriteRemovedModule, + guide: + "Remove a side-effect `import 'openai/shims/node'` (or `web`): openai 5 always uses the runtime's fetch, and the global types must come from lib DOM or @types/node >= 20. `openai/resources/beta/chat/completions` types (ParsedChatCompletion, ParsedChoice, ParsedFunction, runner types) are in `openai/resources/chat/completions`; `openai/src/*` is `openai/*`. From `openai/core`, errors and pagination are in `openai/core/error` and `openai/core/pagination`, and `APIClient` is the `OpenAI` client itself. Ask before replacing an internal the SDK no longer exports.", + }, + { + id: 'core-paths', + summary: + '`openai/error`, `pagination`, `resource`, `streaming` and `uploads` moved to `openai/core/*`; the old paths are deprecated aliases', + severity: 'deprecated', + kinds: [], + symbols: /$^/, + detect: corePathSites, + rewrite: rewriteCorePath, + guide: 'Import from `openai/core/` instead of `openai/`: the same exports.', + }, + { + id: 'request-options', + summary: + 'request options alone are no longer accepted in place of params: pass `{}` (or the params) first', + severity: 'breaking', + kinds: ['type', 'signature'], + symbols: /^TS(?:2353|2559|2345)$/, + message: + /(?:'(?:headers|maxRetries|timeout|signal|idempotencyKey|fetchOptions)' does not exist in type '\w*Params'|has no properties in common with type '\w*Params'|Argument of type '\{ (?:headers|maxRetries|timeout|signal|idempotencyKey|fetchOptions)\b[^']*' is not assignable to parameter of type '\w*Params')/, + detect: requestOptionSites, + rewrite: separateRequestOptions, + guide: + '`client.files.list({ headers })` is `client.files.list({}, { headers })`: a method without required params reads its first argument as params, and request options are always the next argument.', + }, + { + id: 'http-agent', + summary: 'the `httpAgent` client option is removed in favor of `fetchOptions`', + severity: 'breaking', + kinds: ['type', 'removed', 'signature'], + symbols: /ClientOptions#httpAgent$|^TS(?:2353|2561)$/, + message: /(?:^$|'httpAgent')/, + guide: + "openai 5 sends requests with the runtime's fetch, which takes no node:http agent. This is the owner's choice; pick one and report it under decisions, with the alternatives: (a) a proxy agent becomes `fetchOptions: { dispatcher: new ProxyAgent(url) }` with `ProxyAgent` from `undici` (a new dependency); (b) keep-alive or socket tuning becomes an undici `Agent` as `dispatcher`; (c) a custom `fetch` client option when the code already wraps fetch. Keep the proxy URL and every other option; never drop the agent silently.", + }, + { + id: 'file-from-path', + summary: '`fileFromPath` is removed: pass a read stream', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /fileFromPath$|^TS(?:2305|2339|2614|2724)$/, + message: /(?:^$|'fileFromPath')/, + detect: fileFromPathSites, + guide: + "`await fileFromPath(path)` becomes a read stream of the same file. This is the owner's choice; pick one and report it under decisions: (a) `fs.createReadStream(path)`, as the migration guide shows; (b) `await toFile(fs.createReadStream(path), name, { type })` from `openai`, when the code passed a file name or content type that must be kept. Bun code may use `Bun.file(path)`.", + }, + { + id: 'page-helpers', + summary: '`nextPageParams()` and `nextPageInfo()` are replaced by `nextPageRequestOptions()`', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: /#nextPage(?:Params|Info)$|^TS(?:2339|2551)$/, + message: /(?:^$|'nextPage(?:Params|Info)')/, + detect: pageHelperSites, + guide: + 'Prefer `for await (const item of page)` or `page.getNextPage()`; where the code needs the next request itself, `page.nextPageRequestOptions()` returns request options (or null) instead of the `{ params } | { url }` union. Keep the paging logic.', + }, + { + id: 'page-classes', + summary: 'page classes of single methods are now type aliases', + severity: 'breaking', + kinds: ['type', 'removed'], + symbols: /^TS(?:2693|2749|2348|2351)$/, + message: /'\w*Page' only refers to a type/, + guide: + '`FineTuningJobsPage` and its kind are type aliases (`CursorPage`): use them as types only, and `CursorPage` / `Page` from `openai/core/pagination` where a value (instanceof, construction) is needed.', + }, + { + id: 'removed-exports', + summary: + 'internals of `openai/core` and `openai/uploads` (APIClient, BlobLike, createForm, …) and the runFunctions types are no longer exported', + severity: 'breaking', + kinds: ['removed', 'type'], + symbols: + /(?:^|[.#])(?:APIClient|BlobPart|BlobLike|FileLike|ResponseLike|isResponseLike|isBlobLike|isFileLike|isUploadable|isMultipartBody|maybeMultipartFormRequestOptions|multipartFormRequestOptions|createForm)$|^TS(?:2305|2614|2724)$/, + message: + /(?:^$|has no exported member (?:named )?'(?:APIClient|BlobPart|BlobLike|FileLike|ResponseLike|isResponseLike|isBlobLike|isFileLike|isUploadable|isMultipartBody|maybeMultipartFormRequestOptions|multipartFormRequestOptions|createForm|RunnableFunction|RunnableFunctions|ParsingFunction|ChatCompletionFunctionRunnerParams|ChatCompletionStreamingFunctionRunnerParams)')/, + guide: + '`APIClient` is the `OpenAI` client. `Uploadable` and `toFile` stay exported from `openai/core/uploads`; the other upload helpers were internal: use `toFile` or standard `File`/`Blob`. The runFunctions types go with runFunctions (see run-functions). Ask before re-implementing an SDK internal.', + }, + { + id: 'tool-call-union', + summary: + '`ChatCompletionMessageToolCall` and `ChatCompletionTool` are function | custom unions (5.12.1): narrow on `type` before reading `.function`', + severity: 'breaking', + kinds: ['removed', 'type', 'signature'], + symbols: + /(?:ChatCompletionMessageToolCall|ChatCompletionTool)(?:#function|\.Function)$|^TS(?:2339|18046|2322|2345)$/, + message: + /(?:^$|'function' does not exist on type 'ChatCompletion(?:MessageCustomToolCall|CustomTool)'|'[\w.?]*\.function' is of type 'unknown'|ChatCompletionMessageCustomToolCall|ChatCompletionCustomTool)/, + guide: + "A tool call is now `ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall` (and a tool `ChatCompletionFunctionTool | ChatCompletionCustomTool`). Narrow with `if (call.type === 'function')` before reading `call.function`, or type the value as the function variant where the code only ever creates function tools. Decide explicitly what happens to a custom tool call (skip, reject or handle it) and say so; never cast it to the function shape.", + }, + { + id: 'function-output-union', + summary: + 'function and custom tool call outputs in Responses can be content arrays, not only strings (6.0)', + severity: 'breaking', + kinds: ['type', 'signature', 'removed'], + symbols: + /(?:ResponseFunctionToolCallOutputItem|ResponseCustomToolCallOutput)#output$|^TS(?:2339|2322|2345)$/, + message: /(?:^$|ResponseInput(?:Text|Image|File)\b)/, + guide: + '`ResponseFunctionToolCallOutputItem.output` and `ResponseCustomToolCallOutput.output` are `string | Array`. Handle the array case (for text, join the `input_text` parts) where the code assumed a string; do not assert it is a string.', + }, + ], + behavior: openaiBehavior, + instructions: + 'Sources pinned at openai 7.32.0, read 2026-10-14; see RESEARCH.md. Migrate only the reported site, from the compiler error and the guide above. Keep every model name, prompt, message, parameter value and request the code sends: the upgrade changes the SDK, not what the application asks the API. Never cast to any, suppress a diagnostic, or reshape data to the old types to get a clean compile. Where the guide offers options, choose one and report it under decisions.', +}); diff --git a/packages/core/src/packs/openai/openai.test.ts b/packages/core/src/packs/openai/openai.test.ts new file mode 100644 index 00000000..be4b6b12 --- /dev/null +++ b/packages/core/src/packs/openai/openai.test.ts @@ -0,0 +1,266 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { Finding } from '../../domain/report.js'; +import { detectIn, ruleFor } from '../contract.js'; +import { runFixtures } from '../tooling/fixtures.js'; +import type { PackContext } from '../types.js'; +import { openaiPack } from './index.js'; + +it('openai: every fixture rewrites and detects exactly its marked sites', () => { + const result = runFixtures(openaiPack, fileURLToPath(new URL('.', import.meta.url))); + expect(result.cases.length).toBeGreaterThan(0); + expect(result.unknown).toEqual([]); + expect(result.rewriteFailures).toEqual([]); + expect(result.falsePositives).toEqual([]); + expect(result.falseNegatives).toEqual([]); +}); + +const context: PackContext = { from: '4.104.0', to: '7.32.0', includeDeprecated: true }; + +/** The finding `check` reports at a line: a compiler error, or a pack site. */ +function finding( + line: number, + rule: string | undefined, + change: { path?: string; kind?: Finding['change']['kind']; message?: string } = {}, +): Finding { + const path = change.path ?? 'site'; + return { + change: { + package: 'openai', + from: context.from, + to: context.to, + path, + kind: change.kind ?? 'type', + severity: 'breaking', + source: 'types', + confidence: 1, + }, + usage: { + file: 'consumer.ts', + line, + column: 1, + endLine: line, + endColumn: 1, + symbolPath: path, + access: 'call', + snippet: '', + via: 'direct', + ...(change.message ? { compileError: change.message } : {}), + }, + severity: 'breaking', + confidence: 1, + fixability: 'mechanical', + reason: '', + ...(rule ? { rule } : {}), + } as Finding; +} + +const lines = (...code: string[]) => code.join('\n'); + +describe('openai: sites a rule finds but must not rewrite', () => { + it('leaves path ids with side effects to the agent', () => { + const text = lines( + "import OpenAI from 'openai';", + 'const client = new OpenAI();', + 'await client.beta.threads.runs.retrieve(await threadFor(user), runId);', + ); + expect(detectIn(openaiPack, 'consumer.ts', text)).toEqual([ + { rule: 'named-path-params', line: 3 }, + ]); + const out = openaiPack.transform(text, finding(3, 'named-path-params'), context); + expect(out.applied).toBe(false); + expect(out.reason).toMatch(/evaluation order/); + }); + + it('tells a query from request options on steps.list', () => { + const text = lines( + "import OpenAI from 'openai';", + 'const client = new OpenAI();', + 'await client.beta.threads.runs.steps.list(threadId, runId, { limit: 5 });', + 'await client.beta.threads.runs.steps.list(threadId, runId, { timeout: 5 });', + ); + const query = openaiPack.transform(text, finding(3, 'named-path-params'), context); + expect(query.text.split('\n')[2]).toBe( + 'await client.beta.threads.runs.steps.list(runId, { thread_id: threadId, limit: 5 });', + ); + const options = openaiPack.transform(text, finding(4, 'named-path-params'), context); + expect(options.applied).toBe(false); + }); + + it('keeps imports of names that left with runFunctions, and openai/core, for the agent', () => { + const text = lines( + "import { RunnableFunction } from 'openai/resources/beta/chat/completions';", + "import { APIClient } from 'openai/core';", + "import { type ChatCompletionStreamParams } from 'openai/resources/beta/chat/completions';", + ); + expect(detectIn(openaiPack, 'consumer.ts', text).map((s) => s.line)).toEqual([1, 2, 3]); + for (const line of [1, 2]) + expect(openaiPack.transform(text, finding(line, 'removed-modules'), context).applied).toBe( + false, + ); + expect( + openaiPack.transform(text, finding(3, 'removed-modules'), context).text.split('\n')[2], + ).toBe("import { type ChatCompletionStreamParams } from 'openai/resources/chat/completions';"); + }); + + it('keeps a shims import that binds a name', () => { + const text = lines("import { fetch } from 'openai/shims/node';", 'export { fetch };'); + expect(openaiPack.transform(text, finding(1, 'removed-modules'), context).applied).toBe(false); + }); +}); + +describe('openai: what the compiler reports', () => { + const del = "Property 'del' does not exist on type 'Files'."; + it('renames a `del` the compiler places on an SDK resource, through any receiver', () => { + const text = lines('const files = client.files;', 'await files.del(id);'); + const site = finding(2, undefined, { path: 'TS2339', message: del }); + expect(ruleFor(openaiPack.rules, site)?.id).toBe('delete-method'); + expect(openaiPack.transform(text, site, context).text.split('\n')[1]).toBe( + 'await files.delete(id);', + ); + }); + + it('leaves two ids on an unnamed resource: they may have become named params', () => { + const text = 'await files.del(storeId, fileId);'; + const site = finding(1, undefined, { path: 'TS2339', message: del }); + expect(openaiPack.transform(text, site, context).applied).toBe(false); + }); + + it("claims no other library's `del` or a `chat` that is not Beta's", () => { + for (const message of [ + "Property 'del' does not exist on type 'Redis'.", + "Property 'chat' does not exist on type 'Client'.", + ]) + expect( + ruleFor(openaiPack.rules, finding(1, undefined, { path: 'TS2339', message }))?.id, + ).toBeUndefined(); + }); + + it('files the tool-call union under its rule, from the type diff and the compiler', () => { + expect( + ruleFor( + openaiPack.rules, + finding(1, undefined, { + path: 'OpenAI.ChatCompletionMessageToolCall#function', + kind: 'removed', + message: "'raw.function' is of type 'unknown'.", + }), + )?.id, + ).toBe('tool-call-union'); + expect( + ruleFor( + openaiPack.rules, + finding(1, undefined, { + path: 'TS2339', + message: + "Property 'function' does not exist on type 'ChatCompletionMessageCustomToolCall'.", + }), + )?.id, + ).toBe('tool-call-union'); + }); + + it('files a path id passed positionally under named-path-params', () => { + const site = finding(1, undefined, { + path: 'TS2345', + message: + "Argument of type 'string' is not assignable to parameter of type 'RunRetrieveParams'.", + }); + expect(ruleFor(openaiPack.rules, site)?.id).toBe('named-path-params'); + }); +}); + +describe('openai: detection is scoped to files that use the SDK', () => { + it('finds nothing in a file without an openai import', () => { + const text = lines( + 'try { await api.files.del(id); } catch (e: any) { e.headers["retry-after"]; }', + "client.beta.threads.runs.retrieve('t', 'r');", + ); + expect(detectIn(openaiPack, 'consumer.ts', text)).toEqual([]); + }); + + it('reads require() as well as import', () => { + const text = lines( + "const { OpenAI } = require('openai');", + 'const openai = new OpenAI();', + 'openai.files.del(id);', + ); + expect(detectIn(openaiPack, 'consumer.js', text)).toEqual([{ rule: 'delete-method', line: 3 }]); + }); +}); + +describe('openai: decisions carry evidence from the repository', () => { + const decisions = (inventory: { file: string; text: string }[]) => + openaiPack.behavior + .filter((b) => b.reported.includes('decision') && b.applies) + .flatMap((b) => { + const applies = b.applies?.({ ...context, inventory }); + return typeof applies === 'string' ? [[b.id, applies] as const] : []; + }); + + it('names the Node and zod declarations below what openai 7 needs', () => { + const found = Object.fromEntries( + decisions([ + { + file: 'package.json', + text: JSON.stringify({ + engines: { node: '>=18' }, + dependencies: { openai: '^4.83.0', zod: '^3.24.1' }, + devDependencies: { typescript: '^5.7.3', '@types/node': '^20.11.17' }, + }), + }, + { + file: 'src/batch.ts', + text: "import { zodResponseFormat } from 'openai/helpers/zod';", + }, + ]), + ); + expect(found['platform-minimums']).toMatch(/engines\.node ">=18" permits Node below 22\.0\.0/); + expect(found['zod-helpers']).toMatch(/declares zod "\^3\.24\.1"/); + expect(found['zod-helpers']).toMatch(/`src\/batch\.ts` use openai\/helpers\/zod/); + }); + + it('says nothing when the repository already meets the floor and has no helper, agent or subclass', () => { + expect( + decisions([ + { + file: 'package.json', + text: JSON.stringify({ + engines: { node: '>=22' }, + dependencies: { openai: '^4.104.0', zod: '^3.25.76' }, + devDependencies: { typescript: '^5.9.3', '@types/node': '^22.0.0' }, + }), + }, + { + file: 'src/ai.ts', + text: "import OpenAI from 'openai';\nexport const ai = new OpenAI();", + }, + ]).map(([id]) => id), + ).toEqual([]); + }); + + it('finds a subclass of the client, a proxy agent and a lib without DOM', () => { + const found = Object.fromEntries( + decisions([ + { + file: 'package.json', + text: JSON.stringify({ + engines: { node: '22' }, + dependencies: { 'https-proxy-agent': '^7.0.0' }, + devDependencies: { typescript: '^5.9.3' }, + }), + }, + { + file: 'tsconfig.json', + text: '{ // comments are allowed\n "compilerOptions": { "lib": ["ES2020"] } }', + }, + { + file: 'src/client.ts', + text: "import { OpenAI as Base } from 'openai';\nexport class Guarded extends Base {}", + }, + ]), + ); + expect(found['client-subclass']).toMatch(/`src\/client\.ts` extends Base/); + expect(found['builtin-fetch']).toMatch(/depends on https-proxy-agent/); + expect(found['global-types']).toMatch(/without DOM and @types\/node is not declared/); + }); +}); diff --git a/packages/core/src/packs/openai/paths.ts b/packages/core/src/packs/openai/paths.ts new file mode 100644 index 00000000..85b18c3a --- /dev/null +++ b/packages/core/src/packs/openai/paths.ts @@ -0,0 +1,80 @@ +/** + * Methods whose leading path parameters became named in openai 5 (MIGRATION.md, "Named path + * parameters"), checked against the declarations of 4.104.0 and 7.32.0: `keys` are the old + * leading positional ids, in order, as they are named in the new params object; the last id stays + * positional. `after` is what followed the ids in 4.x: nothing (`none`), a required body, or an + * optional query that an overload also let be request options. + * + * `runs.poll`, `runs.submitToolOutputsAndPoll` and `runs.submitToolOutputsStream` changed the + * same way although the guide's list omits them: the 7.x declarations take `(runId, params)`. + */ +export interface NamedPathMethod { + resource: readonly string[]; + method: string; + keys: readonly string[]; + after: 'none' | 'body' | 'query'; +} + +const entry = ( + resource: string, + methods: string[], + keys: string[], + after: NamedPathMethod['after'], +): NamedPathMethod[] => + methods.map((method) => ({ resource: resource.split('.'), method, keys, after })); + +export const NAMED_PATH_METHODS: readonly NamedPathMethod[] = [ + ...entry('vectorStores.files', ['retrieve', 'del', 'content'], ['vector_store_id'], 'none'), + ...entry('vectorStores.files', ['update'], ['vector_store_id'], 'body'), + ...entry('vectorStores.fileBatches', ['retrieve', 'cancel'], ['vector_store_id'], 'none'), + ...entry('vectorStores.fileBatches', ['listFiles'], ['vector_store_id'], 'query'), + ...entry('beta.threads.runs', ['retrieve', 'cancel', 'poll'], ['thread_id'], 'none'), + ...entry( + 'beta.threads.runs', + ['update', 'submitToolOutputs', 'submitToolOutputsAndPoll', 'submitToolOutputsStream'], + ['thread_id'], + 'body', + ), + ...entry('beta.threads.runs.steps', ['retrieve'], ['thread_id', 'run_id'], 'query'), + ...entry('beta.threads.runs.steps', ['list'], ['thread_id'], 'query'), + ...entry('beta.threads.messages', ['retrieve', 'del'], ['thread_id'], 'none'), + ...entry('beta.threads.messages', ['update'], ['thread_id'], 'body'), + ...entry('fineTuning.checkpoints.permissions', ['del'], ['fine_tuned_model_checkpoint'], 'none'), + ...entry('evals.runs', ['retrieve', 'del', 'cancel'], ['eval_id'], 'none'), + ...entry('evals.runs.outputItems', ['retrieve'], ['eval_id', 'run_id'], 'none'), + ...entry('evals.runs.outputItems', ['list'], ['eval_id'], 'query'), + ...entry('containers.files', ['retrieve', 'del'], ['container_id'], 'none'), + ...entry('containers.files.content', ['retrieve'], ['container_id'], 'none'), +]; + +/** `del` methods of 4.x, each now `delete` (MIGRATION.md, "HTTP method naming"). */ +export const DEL_RESOURCES: readonly (readonly string[])[] = [ + 'chat.completions', + 'files', + 'models', + 'fineTuning.checkpoints.permissions', + 'vectorStores', + 'vectorStores.files', + 'beta.assistants', + 'beta.threads', + 'beta.threads.messages', + 'responses', + 'evals', + 'evals.runs', + 'containers', + 'containers.files', +].map((r) => r.split('.')); + +/** + * Keys a 4.x call could pass as request options in place of params; 5.x reads the first argument + * as params only (MIGRATION.md, "Removed request options overloads"). `query` is left out: some + * params objects could carry it too. + */ +export const REQUEST_OPTION_KEYS = new Set([ + 'headers', + 'maxRetries', + 'timeout', + 'signal', + 'idempotencyKey', + 'fetchOptions', +]); diff --git a/packages/core/src/packs/openai/rewrite.ts b/packages/core/src/packs/openai/rewrite.ts new file mode 100644 index 00000000..43db25f9 --- /dev/null +++ b/packages/core/src/packs/openai/rewrite.ts @@ -0,0 +1,386 @@ +import { ts } from 'ts-morph'; +import type { Finding } from '../../domain/report.js'; +import type { TransformResult } from '../types.js'; +import { + betaChatNodes, + CORE_MOVED, + delResource, + namedPathMethod, + optionsOnly, + positional, + REMOVED_MODULE, + RUNNER_RENAMES, + runnerNodes, +} from './detect.js'; +import { type NamedPathMethod, REQUEST_OPTION_KEYS } from './paths.js'; +import { + clientNames, + inert, + methodCalls, + moduleReferences, + parse, + position, + unwrap, +} from './syntax.js'; + +/** + * The deterministic edits. Each finds its construct on the reported line from the file's syntax, + * edits that construct and nothing else, and declines (with a reason) whenever more than one + * reading is possible: the agent then gets the rule's guide instead. + */ +const declined = (text: string, reason: string): TransformResult => ({ + text, + applied: false, + reason, +}); +const splice = (text: string, start: number, end: number, insert: string) => + text.slice(0, start) + insert + text.slice(end); + +/** + * The node of `nodes` on the finding's line. When several are, the last that starts at or before + * the reported column: the compiler reports `client.beta.chat` at `chat`, after `beta`. + */ +function onLine( + source: ts.SourceFile, + nodes: T[], + finding: Finding, +): T | undefined { + const here = nodes.filter((n) => position(source, n).line === finding.usage.line); + if (here.length <= 1) return here[0]; + return here.filter((n) => position(source, n).column <= finding.usage.column).at(-1); +} + +/** `client.beta.chat.completions.parse(…)` → `client.chat.completions.parse(…)`: the same methods, promoted. */ +export function promoteBetaChat(text: string, finding: Finding): TransformResult { + const source = parse(text); + const node = onLine( + source, + betaChatNodes(source).map((n) => n.name), + finding, + )?.parent as ts.PropertyAccessExpression | undefined; + if (!node) return declined(text, 'no `beta.chat.completions` call on the reported line'); + if (node.questionDotToken) + return declined(text, '`?.beta` would lose its optional chaining; edit by hand'); + return { + text: splice(text, node.expression.getEnd(), node.getEnd(), ''), + applied: true, + reason: '`beta.chat.completions` is `chat.completions` in openai 5', + }; +} + +/** Old arguments → new: the last id stays first, the others become params named as in 7.x. */ +function reshape( + text: string, + call: ts.CallExpression, + entry: NamedPathMethod, + source: ts.SourceFile, +): string | { reason: string } { + const args = call.arguments; + const ids = entry.keys.length + 1; + const named = args.slice(0, entry.keys.length); + const last = args[entry.keys.length] as ts.Expression; + if (![...named, last].every(inert)) + return { reason: 'path arguments with side effects would change evaluation order' }; + const pairs = named.map((a, i) => { + const key = entry.keys[i] as string; + const value = a.getText(source); + return value === key ? key : `${key}: ${value}`; + }); + let params: string; + let rest: readonly ts.Expression[]; + const extra = args[ids]; + if (entry.after === 'body' || (entry.after === 'query' && extra && queryNotOptions(extra))) { + const body = extra && unwrap(extra); + if (!body) return { reason: 'the request body is missing' }; + if (ts.isObjectLiteralExpression(body)) { + if (body.properties.some((p) => p.name && entry.keys.includes(p.name.getText(source)))) + return { reason: 'the params already name a path id' }; + params = mergeInto(body, pairs, source); + } else if (inert(body)) params = `{ ${[...pairs, `...${body.getText(source)}`].join(', ')} }`; + else return { reason: 'the request body is an expression that would be evaluated earlier' }; + rest = args.slice(ids + 1); + } else if (entry.after === 'query' && extra) { + return { reason: 'the third argument could be a query or request options' }; + } else { + params = `{ ${pairs.join(', ')} }`; + rest = args.slice(ids); + } + // Keep the call's own layout: one argument per line stays one per line. + const separator = source.text.slice( + (args[0] as ts.Expression).getEnd(), + (args[1] as ts.Expression).getStart(source), + ); + const list = [last.getText(source), params, ...rest.map((a) => a.getText(source))].join( + separator.includes('\n') ? separator : ', ', + ); + const start = (args[0] as ts.Expression).getStart(source); + const end = (args[args.length - 1] as ts.Expression).getEnd(); + return splice(text, start, end, list); +} + +/** An object literal of query keys only, none of which request options also have. */ +function queryNotOptions(node: ts.Expression): boolean { + const value = unwrap(node); + return ( + ts.isObjectLiteralExpression(value) && + value.properties.every( + (p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + !REQUEST_OPTION_KEYS.has(p.name.getText()) && + p.name.getText() !== 'query', + ) + ); +} + +/** `{ tool_outputs }` + `thread_id: id` → `{ thread_id: id, tool_outputs }`, keeping its layout. */ +function mergeInto( + body: ts.ObjectLiteralExpression, + pairs: string[], + source: ts.SourceFile, +): string { + const original = body.getText(source); + const first = body.properties[0]; + if (!first) return `{ ${pairs.join(', ')} }`; + const open = body.getStart(source) + 1; + const between = source.text.slice(open, first.getStart(source)); + const newline = between.lastIndexOf('\n'); + if (newline < 0) return `{ ${pairs.join(', ')}, ${original.slice(1).trimStart()}`; + const indent = between.slice(newline + 1); + return `{${pairs.map((p) => `\n${indent}${p},`).join('')}${original.slice(1)}`; +} + +function rewriteNamedPath( + text: string, + finding: Finding, + options: { delOnly?: boolean } = {}, +): TransformResult { + const source = parse(text); + const candidates = methodCalls(source).filter(({ call, method, chain }) => { + const entry = namedPathMethod(chain, method.text); + if (!entry || (options.delOnly && method.text !== 'del')) return false; + const start = position(source, call).line; + const end = source.getLineAndCharacterOfPosition(call.getEnd()).line + 1; + return start <= finding.usage.line && finding.usage.line <= end && positional(call, entry); + }); + const onTheLine = candidates.filter( + ({ method }) => position(source, method).line === finding.usage.line, + ); + const picked = + onTheLine.length === 1 ? onTheLine[0] : candidates.length === 1 ? candidates[0] : undefined; + if (!picked) + return declined(text, 'no single positional path-parameter call at the reported site'); + const entry = namedPathMethod(picked.chain, picked.method.text) as NamedPathMethod; + const reshaped = reshape(text, picked.call, entry, source); + if (typeof reshaped !== 'string') return declined(text, reshaped.reason); + // The method name precedes the arguments, so renaming it after reshaping keeps offsets valid. + const renamed = + picked.method.text === 'del' + ? splice(reshaped, picked.method.getStart(source), picked.method.getEnd(), 'delete') + : reshaped; + return { + text: renamed, + applied: true, + reason: `${entry.resource.join('.')}.${picked.method.text === 'del' ? 'delete' : picked.method.text} takes ${entry.keys.map((k) => `\`${k}\``).join(' and ')} as named params`, + }; +} + +export const rewriteNamedPathParams = (text: string, finding: Finding) => + rewriteNamedPath(text, finding); + +/** What the compiler says when a 4.x resource's `del` is gone (the resource classes of 7.x). */ +export const SDK_DEL = + /Property 'del' does not exist on type '(?:Files|Models|Completions|VectorStores|Assistants|Threads|Messages|Permissions|Responses|Evals|Runs|Containers)'/; + +/** `.del(id)` → `.delete(id)`; a resource with several path ids is reshaped as well. */ +export function renameDel(text: string, finding: Finding): TransformResult { + const source = parse(text); + const calls = methodCalls(source).filter(({ method }) => method.text === 'del'); + const picked = onLine( + source, + calls.map((c) => c.method), + finding, + ); + if (!picked) return declined(text, 'no `.del(` call on the reported line'); + const call = calls.find((c) => c.method === picked) as (typeof calls)[number]; + if (namedPathMethod(call.chain, 'del')) return rewriteNamedPath(text, finding, { delOnly: true }); + // The compiler (or the type diff) saying an SDK resource lost `del` vouches for the receiver; + // otherwise the chain must, as it does for detection. Two ids through an unnamed receiver + // could be a resource whose ids also became named: that one is the agent's. + const vouched = + SDK_DEL.test(finding.usage.compileError ?? '') || /#del$/.test(finding.change.path); + if (!vouched && !delResource(call.chain, clientNames(source))) + return declined(text, 'the receiver is not known to be an openai resource'); + const second = call.call.arguments[1]; + if (vouched && second && !ts.isObjectLiteralExpression(unwrap(second))) + return declined(text, 'two ids on an unnamed resource: its ids may have become named params'); + return { + text: splice(text, picked.getStart(source), picked.getEnd(), 'delete'), + applied: true, + reason: '`del` is `delete` in openai 5', + }; +} + +/** `'functionCall'` → `'functionToolCall'`, `finalFunctionCall()` → `finalFunctionToolCall()`. */ +export function renameRunner(text: string, finding: Finding): TransformResult { + const source = parse(text); + const node = onLine(source, runnerNodes(source), finding); + if (!node) return declined(text, 'no runner event or method of 4.x on the reported line'); + const name = RUNNER_RENAMES[node.text] as string; + if (ts.isIdentifier(node)) + return { + text: splice(text, node.getStart(source), node.getEnd(), name), + applied: true, + reason: `\`${node.text}()\` is \`${name}()\``, + }; + const start = node.getStart(source) + 1; + return { + text: splice(text, start, start + node.text.length, name), + applied: true, + reason: `the \`${node.text}\` event is \`${name}\``, + }; +} + +/** Exports of `openai/resources/beta/chat/completions` that 7.x has at the non-beta path. */ +const BETA_CHAT_TYPES = new Set([ + 'ParsedFunction', + 'ParsedFunctionToolCall', + 'ParsedChatCompletionMessage', + 'ParsedChoice', + 'ParsedChatCompletion', + 'ChatCompletionParseParams', + 'ChatCompletionStream', + 'ChatCompletionStreamParams', + 'ChatCompletionRunner', + 'ChatCompletionStreamingRunner', + 'ChatCompletionToolRunnerParams', + 'ChatCompletionStreamingToolRunnerParams', + 'ParsingToolFunction', + 'RunnableFunctionWithParse', + 'RunnableFunctionWithoutParse', + 'Completions', +]); + +function importedNames(statement: ts.Node): string[] | undefined { + if (!ts.isImportDeclaration(statement)) return undefined; + const clause = statement.importClause; + if (!clause || clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) + return undefined; + return clause.namedBindings.elements.map((e) => (e.propertyName ?? e.name).text); +} + +/** + * Entry points 5.x removed. A side-effect `import 'openai/shims/node'` goes (the global fetch is + * the only implementation now); named imports of the beta chat types move to + * `openai/resources/chat/completions` when every name exists there; `openai/src` is `openai`. + * Anything else is the agent's, from the guide. + */ +export function rewriteRemovedModule(text: string, finding: Finding): TransformResult { + const source = parse(text); + const references = moduleReferences(source).filter((m) => REMOVED_MODULE.test(m.specifier)); + const reference = onLine( + source, + references.map((m) => m.node), + finding, + ); + const found = references.find((m) => m.node === reference); + if (!found) return declined(text, 'no removed openai entry point on the reported line'); + const { specifier, node, statement } = found; + const quoted = (value: string) => + `${text[node.getStart(source)]}${value}${text[node.getStart(source)]}`; + if (/^openai\/shims\//.test(specifier)) { + const parent = statement.parent; + const sideEffect = + (ts.isImportDeclaration(statement) && !statement.importClause) || + (ts.isCallExpression(statement) && ts.isExpressionStatement(parent)); + const whole = ts.isImportDeclaration(statement) ? statement : parent; + // The statement must be alone on its line (a trailing comment goes with it). + const lines = text.split('\n'); + const { line: at, column } = position(source, whole); + const end = source.getLineAndCharacterOfPosition(whole.getEnd()); + const line = lines[at - 1] ?? ''; + const prefix = line.slice(0, column - 1).trim(); + const rest = line.slice(end.character).trim(); + if (!sideEffect || end.line + 1 !== at || prefix || (rest && !rest.startsWith('//'))) + return declined( + text, + 'a shims import that binds names, or shares its line, needs a hand edit', + ); + lines.splice(at - 1, 1); + return { + text: lines.join('\n'), + applied: true, + reason: `\`${specifier}\` is gone: openai 5 uses the runtime's own fetch`, + }; + } + if (/^openai\/resources\/beta\/chat/.test(specifier)) { + const names = importedNames(statement); + if (!names?.length || !names.every((n) => BETA_CHAT_TYPES.has(n))) + return declined( + text, + 'some imported names are gone with the beta namespace (runFunctions types)', + ); + return { + text: splice( + text, + node.getStart(source), + node.getEnd(), + quoted('openai/resources/chat/completions'), + ), + applied: true, + reason: 'the parse/stream/runner types are in `openai/resources/chat/completions`', + }; + } + if (specifier === 'openai/src' || specifier === 'openai/src/index') + return { + text: splice(text, node.getStart(source), node.getEnd(), quoted('openai')), + applied: true, + reason: '`openai/src` is `openai`', + }; + return declined(text, `\`${specifier}\` has no one-to-one replacement; see the guide`); +} + +/** `openai/error` → `openai/core/error` (and pagination, resource, streaming, uploads). */ +export function rewriteCorePath(text: string, finding: Finding): TransformResult { + const source = parse(text); + const references = moduleReferences(source).filter((m) => CORE_MOVED.test(m.specifier)); + const node = onLine( + source, + references.map((m) => m.node), + finding, + ); + const moved = node && CORE_MOVED.exec(node.text); + if (!node || !moved) return declined(text, 'no moved openai path on the reported line'); + const quote = text[node.getStart(source)]; + return { + text: splice( + text, + node.getStart(source), + node.getEnd(), + `${quote}openai/core/${moved[1]}${quote}`, + ), + applied: true, + reason: `\`${node.text}\` is \`openai/core/${moved[1]}\``, + }; +} + +/** `list({ headers })` → `list({}, { headers })`: request options are the second argument now. */ +export function separateRequestOptions(text: string, finding: Finding): TransformResult { + const source = parse(text); + const calls = methodCalls(source).filter(({ call }) => { + const only = + call.arguments.length === 1 ? unwrap(call.arguments[0] as ts.Expression) : undefined; + return only !== undefined && optionsOnly(only, source); + }); + const picked = onLine( + source, + calls.map((c) => c.call.arguments[0] as ts.Expression), + finding, + ); + if (!picked) return declined(text, 'no call passing only request options on the reported line'); + const start = picked.getStart(source); + return { + text: splice(text, start, start, '{}, '), + applied: true, + reason: 'request options are the second argument; the params are `{}`', + }; +} diff --git a/packages/core/src/packs/openai/syntax.ts b/packages/core/src/packs/openai/syntax.ts new file mode 100644 index 00000000..0b382990 --- /dev/null +++ b/packages/core/src/packs/openai/syntax.ts @@ -0,0 +1,279 @@ +import { ts } from 'ts-morph'; +import { onReset } from '../../shared-state.js'; +import type { SourceSite } from '../contract.js'; + +/** + * What the openai rules read from a file's syntax: whether it uses the package, the property + * chain a call goes through (`client.beta.threads.runs.retrieve`), and which names hold a client. + * Text alone cannot tell `runs.retrieve(a, b)` on an OpenAI client from any other `retrieve`; the + * chain and the import can. Nothing here resolves types, so it also sees code typed `any`. + */ +const parsed = new Map(); +onReset(() => parsed.clear()); + +export function parse(text: string): ts.SourceFile { + let source = parsed.get(text); + if (!source) { + source = ts.createSourceFile( + 'consumer.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + if (parsed.size >= 16) parsed.delete(parsed.keys().next().value as string); + parsed.set(text, source); + } + return source; +} + +export const OPENAI_MODULE = /^openai(?:\/|$)/; + +/** Module specifiers of the file's imports, re-exports, `require` and `import()` calls, with their nodes. */ +export function moduleReferences( + source: ts.SourceFile, +): { specifier: string; node: ts.StringLiteralLike; statement: ts.Node }[] { + const found: { specifier: string; node: ts.StringLiteralLike; statement: ts.Node }[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) + found.push({ + specifier: node.moduleSpecifier.text, + node: node.moduleSpecifier, + statement: node, + }); + else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0] as ts.Expression) + ) { + const argument = node.arguments[0] as ts.StringLiteralLike; + found.push({ specifier: argument.text, node: argument, statement: node }); + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + +/** Whether the file uses the package at all: a rule's sites are only ever in such a file. */ +export function usesOpenai(text: string): boolean { + if (!text.includes('openai')) return false; + return moduleReferences(parse(text)).some((m) => OPENAI_MODULE.test(m.specifier)); +} + +/** 1-based line and column of a node's first token. */ +export function position(source: ts.SourceFile, node: ts.Node): { line: number; column: number } { + const at = source.getLineAndCharacterOfPosition(node.getStart(source)); + return { line: at.line + 1, column: at.character + 1 }; +} + +export function siteOf(source: ts.SourceFile, node: ts.Node, name?: string): SourceSite { + const { line, column } = position(source, node); + return { + line, + column, + snippet: source.text.split('\n')[line - 1]?.trim() ?? '', + ...(name ? { name } : {}), + }; +} + +/** `(x as Y)`, `x!`, `(x)`: the expression underneath, which is what the chain is about. */ +export function unwrap(node: ts.Expression): ts.Expression { + let current = node; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isSatisfiesExpression(current) + ) + current = current.expression; + return current; +} + +/** + * The names of a property-access chain, root first: `this.client.beta.threads` → + * `['this', 'client', 'beta', 'threads']`. A root that is a call or anything else is `()`; + * an element access (`x['beta']`) is not a chain the rules read. + */ +export function chainOf(node: ts.Expression): string[] | undefined { + const names: string[] = []; + let current = unwrap(node); + while (ts.isPropertyAccessExpression(current)) { + names.unshift(current.name.text); + current = unwrap(current.expression); + } + if (ts.isIdentifier(current)) names.unshift(current.text); + else if (current.kind === ts.SyntaxKind.ThisKeyword) names.unshift('this'); + else if (ts.isElementAccessExpression(current)) return undefined; + else names.unshift('()'); + return names; +} + +/** Whether `chain` ends with `suffix`, segment by segment. */ +export function endsWith(chain: readonly string[], suffix: readonly string[]): boolean { + if (chain.length < suffix.length) return false; + const offset = chain.length - suffix.length; + return suffix.every((name, i) => chain[offset + i] === name); +} + +/** Every call in the file whose callee is `.`, with the receiver's chain. */ +export function methodCalls( + source: ts.SourceFile, +): { call: ts.CallExpression; method: ts.Identifier; chain: string[] }[] { + const found: { call: ts.CallExpression; method: ts.Identifier; chain: string[] }[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = unwrap(node.expression); + if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) { + const chain = chainOf(callee.expression); + if (chain) found.push({ call: node, method: callee.name, chain }); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + +/** + * Names that hold an OpenAI client in this file: variables, parameters and properties + * initialized with `new OpenAI(...)` / `new AzureOpenAI(...)` (as imported here) or typed with + * them. Properties are `this.`. A resource reached from one of these is the SDK's. + */ +export function clientNames(source: ts.SourceFile): Set { + const { classes, namespaces } = clientClasses(source); + const isClient = (expression: ts.Expression | undefined): boolean => { + const value = expression && unwrap(expression); + if (!value || !ts.isNewExpression(value)) return false; + const chain = chainOf(value.expression); + if (!chain) return false; + return chain.length === 1 + ? classes.has(chain[0] as string) + : chain.length === 2 && + namespaces.has(chain[0] as string) && + ['OpenAI', 'AzureOpenAI'].includes(chain[1] as string); + }; + const isClientType = (type: ts.TypeNode | undefined): boolean => + !!type && + ts.isTypeReferenceNode(type) && + (ts.isIdentifier(type.typeName) + ? classes.has(type.typeName.text) + : ts.isIdentifier(type.typeName.left) && + namespaces.has(type.typeName.left.text) && + ['OpenAI', 'AzureOpenAI'].includes(type.typeName.right.text)); + const names = new Set(); + const visit = (node: ts.Node): void => { + if ( + (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isPropertyDeclaration(node)) && + (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) && + (isClient(node.initializer) || isClientType(node.type)) + ) + names.add(ts.isPropertyDeclaration(node) ? `this.${node.name.text}` : node.name.text); + // `constructor(private client: OpenAI)` is also `this.client`. + if ( + ts.isParameter(node) && + ts.isIdentifier(node.name) && + node.modifiers?.some((m) => PARAMETER_PROPERTY.has(m.kind)) && + isClientType(node.type) + ) + names.add(`this.${node.name.text}`); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + isClient(node.right) + ) { + const chain = chainOf(node.left); + if (chain?.length === 1) names.add(chain[0] as string); + if (chain?.length === 2 && chain[0] === 'this') names.add(`this.${chain[1]}`); + } + ts.forEachChild(node, visit); + }; + visit(source); + return names; +} + +/** + * Local names of the client classes this file imports from `openai` (default, `OpenAI`, + * `AzureOpenAI`, aliases included, or destructured from `require`), and namespace imports. + */ +export function clientClasses(source: ts.SourceFile): { + classes: Set; + namespaces: Set; +} { + const classes = new Set(); + const namespaces = new Set(); + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === 'openai' + ) { + const clause = statement.importClause; + if (clause?.name) classes.add(clause.name.text); + const bindings = clause?.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text); + if (bindings && ts.isNamedImports(bindings)) + for (const element of bindings.elements) + if (['OpenAI', 'AzureOpenAI'].includes((element.propertyName ?? element.name).text)) + classes.add(element.name.text); + } + if (ts.isVariableStatement(statement)) + for (const declaration of statement.declarationList.declarations) { + const init = declaration.initializer && unwrap(declaration.initializer); + if ( + init && + ts.isCallExpression(init) && + ts.isIdentifier(init.expression) && + init.expression.text === 'require' && + ts.isStringLiteralLike(init.arguments[0] as ts.Expression) && + (init.arguments[0] as ts.StringLiteralLike).text === 'openai' + ) { + if (ts.isIdentifier(declaration.name)) classes.add(declaration.name.text); + else if (ts.isObjectBindingPattern(declaration.name)) + for (const element of declaration.name.elements) + if (ts.isIdentifier(element.name)) classes.add(element.name.text); + } + } + } + return { classes, namespaces }; +} + +/** Whether a chain's root (`client`, `this.client`) is one of `clientNames`. */ +export function rootIsClient( + chain: readonly string[], + clients: ReadonlySet, + depth: number, +): boolean { + const root = chain.slice(0, chain.length - depth); + return root.length === 1 + ? clients.has(root[0] as string) + : root.length === 2 && root[0] === 'this' && clients.has(`this.${root[1]}`); +} + +/** An argument the rewrite may move without changing what runs: a name, a member, a literal. */ +export function inert(node: ts.Expression): boolean { + const value = unwrap(node); + if ( + ts.isIdentifier(value) || + ts.isStringLiteralLike(value) || + ts.isNumericLiteral(value) || + value.kind === ts.SyntaxKind.ThisKeyword + ) + return true; + if (ts.isPropertyAccessExpression(value)) return inert(value.expression); + return false; +} + +const PARAMETER_PROPERTY = new Set([ + ts.SyntaxKind.PublicKeyword, + ts.SyntaxKind.PrivateKeyword, + ts.SyntaxKind.ProtectedKeyword, + ts.SyntaxKind.ReadonlyKeyword, +]); diff --git a/packages/core/src/packs/openai/verification.json b/packages/core/src/packs/openai/verification.json new file mode 100644 index 00000000..34793e7c --- /dev/null +++ b/packages/core/src/packs/openai/verification.json @@ -0,0 +1,31 @@ +{ + "status": "candidate", + "truth": "sha256-ff39a7277a212ba12d5cedb40a97a79ebefd6729d2f0cbdd89bee78e1aa3f7cc", + "repos": 7, + "breaking": { + "precision": 1, + "recall": 0.15, + "falsePositives": 0, + "truePositives": 3, + "falseNegatives": 17 + }, + "behavior": { + "precision": 1, + "recall": 1, + "falsePositives": 0, + "truePositives": 0, + "falseNegatives": 0 + }, + "class": "breaking", + "breakingSites": 20, + "behaviorSites": 0, + "unexercised": [ + "beta-chat", + "core-paths", + "delete-method", + "named-path-params", + "removed-modules", + "request-options", + "runner-renames" + ] +} diff --git a/packages/core/src/packs/registry.ts b/packages/core/src/packs/registry.ts index 1553058b..b6b66e91 100644 --- a/packages/core/src/packs/registry.ts +++ b/packages/core/src/packs/registry.ts @@ -12,6 +12,8 @@ import { lucideReactPack } from './lucide-react/index.js'; import lucideReactVerification from './lucide-react/verification.json' with { type: 'json' }; import { nextPack } from './next/index.js'; import nextVerification from './next/verification.json' with { type: 'json' }; +import { openaiPack } from './openai/index.js'; +import openaiVerification from './openai/verification.json' with { type: 'json' }; import { reactPack } from './react/index.js'; import reactVerification from './react/verification.json' with { type: 'json' }; import { reactRouterPack } from './react-router/index.js'; @@ -43,6 +45,7 @@ export const REGISTRY: readonly RegisteredPack[] = [ verification: lucideReactVerification as PackVerification, }, { dir: 'next', pack: nextPack, verification: nextVerification as PackVerification }, + { dir: 'openai', pack: openaiPack, verification: openaiVerification as PackVerification }, { dir: 'react', pack: reactPack, verification: reactVerification as PackVerification }, { dir: 'react-router', diff --git a/packs/queue.json b/packs/queue.json index 68a1f123..2dd7d4a1 100644 --- a/packs/queue.json +++ b/packs/queue.json @@ -222,7 +222,7 @@ "from": ">=4 <5", "to": ">=7 <8", "why": "declared directly in 16 of 52 sample repositories, 16 of them on an older major; 1299 breaking API changes, 825 distinct (801 removed or renamed, 24 change shapes) from 4.104.0 to 7.30.0", - "status": "todo", + "status": "candidate", "repos": 16, "reposBehind": 16, "weeklyDownloads": 0,