diff --git a/package-lock.json b/package-lock.json index 08d63a44a..a282f3714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4276,6 +4276,7 @@ "version": "6.40.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -5542,7 +5543,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", - "openai": "6.40.0", + "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, @@ -5581,6 +5582,43 @@ "node": ">= 20" } }, + "packages/ai/node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "packages/chord": { "name": "@earendil-works/chord", "version": "0.87.1", diff --git a/packages/ai/package.json b/packages/ai/package.json index ebd7865f7..bc08be7a2 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -70,7 +70,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", - "openai": "6.40.0", + "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 829ac2827..e4ebb7ce6 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -827,7 +827,7 @@ function buildParams( }; if (compat.supportsUsageInStreaming !== false) { - (params as any).stream_options = { include_usage: true }; + params.stream_options = { include_usage: true }; } if (compat.supportsStore) { @@ -836,7 +836,8 @@ function buildParams( if (options?.maxTokens) { if (compat.maxTokensField === "max_tokens") { - (params as any).max_tokens = options.maxTokens; + // Deprecated by OpenAI, but some OpenAI-compatible providers only accept max_tokens. + (params as { max_tokens?: number }).max_tokens = options.maxTokens; } else { params.max_completion_tokens = options.maxTokens; } diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 4025c0412..61ed86288 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -92,7 +92,7 @@ function getPromptCacheRetention( function getPromptCacheOptions( compat: Required, cacheRetention: CacheRetention, -): { mode?: "explicit"; ttl?: "30m" } | undefined { +): ResponseCreateParamsStreaming["prompt_cache_options"] { if (!compat.supportsExplicitPromptCacheMode) return undefined; if (cacheRetention === "none") return { mode: "explicit" }; if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" }; @@ -307,9 +307,7 @@ function buildParams( }); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); - const params: ResponseCreateParamsStreaming & { - prompt_cache_options?: { mode?: "explicit"; ttl?: "30m" }; - } = { + const params: ResponseCreateParamsStreaming = { model: model.id, input: messages, stream: true, diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 0eedd76e9..d1eba9d6f 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -425,7 +425,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", - "openai": "6.40.0", + "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, @@ -459,6 +459,43 @@ "node": ">= 20" } }, + "node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.87.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.87.1.tgz", @@ -1630,24 +1667,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/openai": { - "version": "6.40.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", - "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", - "license": "Apache-2.0", - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 5086a308c..260336f99 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -447,7 +447,7 @@ "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", - "openai": "6.40.0", + "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, @@ -481,6 +481,43 @@ "node": ">= 20" } }, + "node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@earendil-works/pi-telemetry": { "version": "0.87.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.1.tgz", @@ -1620,24 +1657,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/openai": { - "version": "6.40.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", - "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", - "license": "Apache-2.0", - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", diff --git a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts index 6e9c45185..591f844b5 100644 --- a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts @@ -1,46 +1,34 @@ import { complete, resetApiProviders } from "@earendil-works/pi-ai/compat"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRegistry } from "../src/core/model-registry.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; -const openAIState = vi.hoisted(() => ({ clientOptions: undefined as unknown })); +interface CapturedRequest { + url: string; + headers: Headers; +} -vi.mock("openai", () => { - class FakeOpenAI { - constructor(options: unknown) { - openAIState.clientOptions = options; - } - - chat = { - completions: { - create: () => { - const stream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }; - }, - }; - const promise = Promise.resolve(stream) as Promise & { - withResponse(): Promise<{ - data: typeof stream; - response: { status: number; headers: Headers }; - }>; - }; - promise.withResponse = async () => ({ - data: stream, - response: { status: 200, headers: new Headers() }, - }); - return promise; - }, - }, +function createCapturingFetch(): { fetch: typeof globalThis.fetch; requests: CapturedRequest[] } { + const requests: CapturedRequest[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + requests.push({ url: request.url, headers: request.headers }); + const chunk = { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 0, + model: "test", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }; - } - - return { default: FakeOpenAI }; -}); + return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + return { fetch, requests }; +} async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; modelRegistry: ModelRegistry }> { const authStorage = AuthStorage.inMemory(); @@ -56,6 +44,8 @@ async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; return { modelRuntime, modelRegistry: new ModelRegistry(modelRuntime) }; } +const CLOUDFLARE_COMPAT_URL = "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions"; + describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => { const { modelRuntime } = await createCloudflareRuntime(); @@ -63,14 +53,13 @@ describe("ModelRegistry Cloudflare compat streaming", () => { expect(model).toBeDefined(); resetApiProviders(); - await modelRuntime.completeSimple(model!, { messages: [] }); + const { fetch, requests } = createCapturingFetch(); + const result = await modelRuntime.completeSimple(model!, { messages: [] }, { fetch }); - const clientOptions = openAIState.clientOptions as { - baseURL?: string; - defaultHeaders?: Record; - }; - expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat"); - expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test-token"); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(CLOUDFLARE_COMPAT_URL); + expect(requests[0].headers.get("cf-aig-authorization")).toBe("Bearer test-token"); }); it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => { @@ -88,15 +77,14 @@ describe("ModelRegistry Cloudflare compat streaming", () => { "x-api-key": null, }); - await complete(model!, { messages: [] }, auth); + const { fetch, requests } = createCapturingFetch(); + const result = await complete(model!, { messages: [] }, { ...auth, fetch }); - const clientOptions = openAIState.clientOptions as { - baseURL?: string; - defaultHeaders?: Record; - }; - expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat"); - expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test-token"); - expect(clientOptions.defaultHeaders?.Authorization).toBeNull(); - expect(clientOptions.defaultHeaders?.["x-api-key"]).toBeNull(); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(CLOUDFLARE_COMPAT_URL); + expect(requests[0].headers.get("cf-aig-authorization")).toBe("Bearer test-token"); + expect(requests[0].headers.has("authorization")).toBe(false); + expect(requests[0].headers.has("x-api-key")).toBe(false); }); });