diff --git a/typescript/package.json b/typescript/package.json index 902d08b2..109fe49d 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -21,6 +21,7 @@ }, "scripts": { "build": "tsc -p src", + "test": "npm run build && node --test tests/*.mjs", "build-all": "npm run build --workspaces", "prepare": "npm run build-all", "prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"", diff --git a/typescript/src/model.ts b/typescript/src/model.ts index ff3523aa..46f1cb95 100644 --- a/typescript/src/model.ts +++ b/typescript/src/model.ts @@ -83,6 +83,8 @@ export interface TypeChatLanguageModel { * If an `OPENAI_API_KEY` environment variable exists, the `createOpenAILanguageModel` function * is used to create the instance. The `OPENAI_ENDPOINT` and `OPENAI_MODEL` environment variables * must also be defined or an exception will be thrown. + * To use the OpenAI Responses API, set `OPENAI_ENDPOINT` to a URL whose path ends with `/responses` + * (e.g. `https://api.openai.com/v1/responses`); otherwise the Chat Completions API is used. * * If an `AZURE_OPENAI_API_KEY` environment variable exists, the `createAzureOpenAILanguageModel` function * is used to create the instance. The `AZURE_OPENAI_ENDPOINT` environment variable must also be defined @@ -95,8 +97,8 @@ export function createLanguageModel(env: Record): Ty if (env.OPENAI_API_KEY) { const apiKey = env.OPENAI_API_KEY ?? missingEnvironmentVariable("OPENAI_API_KEY"); const model = env.OPENAI_MODEL ?? missingEnvironmentVariable("OPENAI_MODEL"); - const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/chat/completions"; const org = env.OPENAI_ORGANIZATION ?? ""; + const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/chat/completions"; return createOpenAILanguageModel(apiKey, model, endPoint, org); } if (env.AZURE_OPENAI_API_KEY) { @@ -109,17 +111,30 @@ export function createLanguageModel(env: Record): Ty /** * Creates a language model encapsulation of an OpenAI REST API endpoint. + * + * When `endPoint` (or `useResponsesApi`) indicates the Responses API the function routes through + * the `/v1/responses` request/response format; otherwise the Chat Completions format is used. + * The Responses API is auto-detected when the endpoint URL path ends with `/responses` + * (e.g. `https://api.openai.com/v1/responses`). * @param apiKey The OpenAI API key. - * @param model The model name. - * @param endPoint The URL of the OpenAI REST API endpoint. Defaults to "https://api.openai.com/v1/chat/completions". + * @param model The model name (e.g. `"gpt-4o"`). + * @param endPoint The URL of the OpenAI REST API endpoint. Defaults to + * `"https://api.openai.com/v1/chat/completions"`. Supply a `/responses` URL to use the + * Responses API instead. * @param org The OpenAI organization id. + * @param useResponsesApi When `true`, forces the Responses API regardless of the endpoint URL. + * When `false`, forces Chat Completions. When `undefined` (default), the API variant is + * inferred from the endpoint URL. * @returns An instance of `TypeChatLanguageModel`. */ -export function createOpenAILanguageModel(apiKey: string, model: string, endPoint = "https://api.openai.com/v1/chat/completions", org = ""): TypeChatLanguageModel { +export function createOpenAILanguageModel(apiKey: string, model: string, endPoint = "https://api.openai.com/v1/chat/completions", org = "", useResponsesApi?: boolean): TypeChatLanguageModel { const headers = { "Authorization": `Bearer ${apiKey}`, "OpenAI-Organization": org }; + if ((useResponsesApi ?? isResponsesApiUrl(endPoint))) { + return createResponsesFetchLanguageModel(endPoint, headers, { model }); + } return createFetchLanguageModel(endPoint, headers, { model }); } @@ -181,12 +196,105 @@ function createFetchLanguageModel(url: string, headers: object, defaultParams: o if (!isTransientHttpError(response.status) || retryCount >= retryMaxAttempts) { return error(`REST API error ${response.status}: ${response.statusText}`); } - await sleep(retryPauseMs); + await sleep(getRetryDelayMs(response, retryPauseMs, retryPauseMs * retryMaxAttempts)); + retryCount++; + } + } +} + +/** + * OpenAI Responses API endpoint encapsulation using the fetch API. + * + * The Responses API uses a different request and response shape from Chat Completions: + * - **Request body**: `input` (string or array of `PromptSection`) instead of `messages`. + * - **Response body**: text is returned inside `output[n].content[m].text` where the matching + * output item has `type === "message"` and the content item has `type === "output_text"`. + * + * Example successful response: + * ```json + * { + * "id": "resp_...", + * "output": [ + * { + * "type": "message", + * "role": "assistant", + * "content": [{ "type": "output_text", "text": "Hello!" }] + * } + * ] + * } + * ``` + * + * @param url The Responses API endpoint URL (path should end with `/responses`). + * @param headers HTTP headers to include in every request (e.g. `Authorization`). + * @param defaultParams Additional JSON body parameters merged into every request (e.g. `{ model }`). + */ +function createResponsesFetchLanguageModel(url: string, headers: object, defaultParams: object) { + const model: TypeChatLanguageModel = { + complete + }; + return model; + + async function complete(prompt: string | PromptSection[]) { + let retryCount = 0; + const retryMaxAttempts = model.retryMaxAttempts ?? 3; + const retryPauseMs = model.retryPauseMs ?? 1000; + const input = typeof prompt === "string" ? prompt : (prompt as PromptSection[]); + while (true) { + const options = { + method: "POST", + body: JSON.stringify({ + ...defaultParams, + input, + temperature: 0, + }), + headers: { + "content-type": "application/json", + ...headers + } + } + const response = await fetch(url, options); + if (response.ok) { + type ResponsesAPIOutputItem = { + type: string; + role?: string; + content: { type: string; text: string }[]; + }; + const json = await response.json() as { output: ResponsesAPIOutputItem[] }; + const message = json.output?.find(o => o.type === "message"); + const textContent = message?.content?.find(c => c.type === "output_text"); + if (textContent?.text !== undefined) { + return success(textContent.text); + } else { + return error(`REST API unexpected response format: ${JSON.stringify(json)}`); + } + } + if (!isTransientHttpError(response.status) || retryCount >= retryMaxAttempts) { + return error(`REST API error ${response.status}: ${response.statusText}`); + } + await sleep(getRetryDelayMs(response, retryPauseMs, retryPauseMs * retryMaxAttempts)); retryCount++; } } } +/** + * Returns the number of milliseconds to wait before the next retry attempt. + * When the response carries a `Retry-After` header (sent by servers on 429 Too Many Requests + * and 503 Service Unavailable), its value (in seconds) is used as the delay, capped at + * `maxMs` to avoid waiting longer than the configured total retry budget. + * For all other transient errors the default pause is returned. + */ +function getRetryDelayMs(response: Response, defaultMs: number, maxMs: number): number { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds)) { + return Math.min(seconds * 1000, maxMs); + } + } + return defaultMs; +} + /** * Returns true of the given HTTP status code represents a transient error. */ @@ -202,6 +310,21 @@ function isTransientHttpError(code: number): boolean { return false; } +/** + * Returns true when the given URL targets the OpenAI Responses API. + * Detection is based on whether the URL path ends with `/responses` (before any query string). + * This covers both the standard OpenAI endpoint (`https://api.openai.com/v1/responses`) and + * Azure OpenAI deployments that end with `/responses?api-version=...`. + */ +function isResponsesApiUrl(url: string): boolean { + try { + return new URL(url).pathname.endsWith("/responses"); + } catch { + // Fallback for relative or non-standard URLs + return url.split("?")[0].endsWith("/responses"); + } +} + /** * Sleeps for the given number of milliseconds. */ diff --git a/typescript/tests/model.test.mjs b/typescript/tests/model.test.mjs new file mode 100644 index 00000000..a692fa19 --- /dev/null +++ b/typescript/tests/model.test.mjs @@ -0,0 +1,327 @@ +/** + * Tests for model.ts - verifies backward compatibility of Chat Completions API + * and correct behavior of the new Responses API support. + * + * These tests use mocked fetch to avoid requiring real API keys. + */ + +import { test, describe, after } from "node:test"; +import assert from "node:assert/strict"; + +// Load the compiled module from dist +import { createOpenAILanguageModel, createLanguageModel } from "../dist/index.js"; + +// --------------------------------------------------------------------------- +// Helpers: build mock Response objects +// --------------------------------------------------------------------------- + +function makeChatCompletionsResponse(content) { + return { + ok: true, + status: 200, + headers: { get: (_name) => null }, + json: () => + Promise.resolve({ + id: "chatcmpl-123", + object: "chat.completion", + choices: [{ message: { role: "assistant", content } }], + }), + }; +} + +function makeResponsesAPIResponse(text) { + return { + ok: true, + status: 200, + headers: { get: (_name) => null }, + json: () => + Promise.resolve({ + id: "resp-123", + object: "response", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + ], + }), + }; +} + +function makeErrorResponse(status, statusText, retryAfterSec = null) { + return { + ok: false, + status, + statusText, + headers: { + get: (name) => + name.toLowerCase() === "retry-after" && retryAfterSec !== null + ? String(retryAfterSec) + : null, + }, + }; +} + +// --------------------------------------------------------------------------- +// Mock fetch utility +// --------------------------------------------------------------------------- + +let capturedRequests = []; +let mockResponses = []; + +function setupFetch(responses) { + capturedRequests = []; + mockResponses = [...responses]; + globalThis.fetch = async (url, options) => { + capturedRequests.push({ url, options }); + const resp = mockResponses.shift(); + if (!resp) throw new Error("No mock response configured"); + return resp; + }; +} + +function teardownFetch() { + delete globalThis.fetch; + capturedRequests = []; + mockResponses = []; +} + +// --------------------------------------------------------------------------- +// Chat Completions API (backward compatibility) +// --------------------------------------------------------------------------- + +describe("createOpenAILanguageModel (Chat Completions API)", () => { + after(teardownFetch); + + test("uses /chat/completions endpoint by default", async () => { + setupFetch([makeChatCompletionsResponse("Hello!")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + const result = await model.complete("Say hello"); + assert.equal(result.success, true); + assert.equal(result.data, "Hello!"); + assert.ok( + capturedRequests[0].url.includes("/chat/completions"), + "Expected /chat/completions URL" + ); + }); + + test("sends messages field in request body", async () => { + setupFetch([makeChatCompletionsResponse("Hi!")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + await model.complete("Say hi"); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok(Array.isArray(body.messages), "Expected messages array"); + assert.equal(body.messages[0].content, "Say hi"); + assert.equal(body.messages[0].role, "user"); + }); + + test("parses string content from choices[0].message.content", async () => { + setupFetch([makeChatCompletionsResponse("The answer is 42.")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + const result = await model.complete("What is the answer?"); + assert.equal(result.success, true); + assert.equal(result.data, "The answer is 42."); + }); + + test("accepts PromptSection array as input", async () => { + setupFetch([makeChatCompletionsResponse("OK")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + const prompt = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello" }, + ]; + const result = await model.complete(prompt); + assert.equal(result.success, true); + const body = JSON.parse(capturedRequests[0].options.body); + assert.equal(body.messages.length, 2); + }); + + test("returns error on non-transient HTTP error", async () => { + setupFetch([makeErrorResponse(401, "Unauthorized")]); + const model = createOpenAILanguageModel("invalid-key", "gpt-4"); + const result = await model.complete("test"); + assert.equal(result.success, false); + assert.ok(result.message.includes("401")); + }); + + test("auto-detects Responses API from a /responses endpoint URL", async () => { + setupFetch([makeResponsesAPIResponse("Auto-detected!")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "Auto-detected!"); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok("input" in body, "Expected input field (Responses API) from auto-detection"); + assert.ok(!("messages" in body), "Should NOT have messages field when auto-detected as Responses API"); + }); + + test("uses Responses API when useResponsesApi=true regardless of URL", async () => { + setupFetch([makeResponsesAPIResponse("Forced!")]); + // Passing a chat/completions URL but forcing Responses API via the flag + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/chat/completions", "", true); + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "Forced!"); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok("input" in body, "Expected input field when useResponsesApi=true"); + }); + + test("respects retry-after header on 429", async () => { + setupFetch([ + makeErrorResponse(429, "Too Many Requests", 0), // Retry-After: 0s (immediate) + makeChatCompletionsResponse("OK after retry"), + ]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + model.retryMaxAttempts = 3; + model.retryPauseMs = 1000; + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "OK after retry"); + assert.equal(capturedRequests.length, 2, "Expected 2 requests: initial + 1 retry"); + }); + + test("respects retry-after header on 503", async () => { + setupFetch([ + makeErrorResponse(503, "Service Unavailable", 0), // Retry-After: 0s (immediate) + makeChatCompletionsResponse("OK after 503 retry"), + ]); + const model = createOpenAILanguageModel("sk-test", "gpt-4"); + model.retryMaxAttempts = 3; + model.retryPauseMs = 1000; + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "OK after 503 retry"); + assert.equal(capturedRequests.length, 2, "Expected 2 requests: initial + 1 retry"); + }); +}); + +// --------------------------------------------------------------------------- +// Responses API via createOpenAILanguageModel +// --------------------------------------------------------------------------- + +describe("createOpenAILanguageModel (Responses API path)", () => { + after(teardownFetch); + + test("sends input field (not messages) in request body", async () => { + setupFetch([makeResponsesAPIResponse("Hi!")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + await model.complete("Say hi"); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok("input" in body, "Expected input field in request body"); + assert.ok(!("messages" in body), "Should NOT have messages field"); + }); + + test("parses text from output[0].content[0].text", async () => { + setupFetch([makeResponsesAPIResponse("The answer is 42.")]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + const result = await model.complete("What is the answer?"); + assert.equal(result.success, true); + assert.equal(result.data, "The answer is 42."); + }); + + test("returns error on unexpected response format", async () => { + setupFetch([{ + ok: true, + status: 200, + headers: { get: (_name) => null }, + json: () => Promise.resolve({ output: [] }), + }]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + const result = await model.complete("test"); + assert.equal(result.success, false); + assert.ok(result.message.includes("unexpected response format")); + }); + + test("respects retry-after header on 429 for Responses API path", async () => { + setupFetch([ + makeErrorResponse(429, "Too Many Requests", 0), // Retry-After: 0s (immediate) + makeResponsesAPIResponse("OK after retry"), + ]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + model.retryMaxAttempts = 3; + model.retryPauseMs = 1000; + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "OK after retry"); + assert.equal(capturedRequests.length, 2, "Expected 2 requests: initial + 1 retry"); + }); + + test("respects retry-after header on 503 for Responses API path", async () => { + setupFetch([ + makeErrorResponse(503, "Service Unavailable", 0), // Retry-After: 0s (immediate) + makeResponsesAPIResponse("OK after 503 retry"), + ]); + const model = createOpenAILanguageModel("sk-test", "gpt-4", "https://api.openai.com/v1/responses"); + model.retryMaxAttempts = 3; + model.retryPauseMs = 1000; + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.equal(result.data, "OK after 503 retry"); + assert.equal(capturedRequests.length, 2, "Expected 2 requests: initial + 1 retry"); + }); +}); + +// --------------------------------------------------------------------------- +// createLanguageModel env-var routing +// --------------------------------------------------------------------------- + +describe("createLanguageModel environment variable routing", () => { + after(teardownFetch); + + test("defaults to Chat Completions API", async () => { + setupFetch([makeChatCompletionsResponse("OK")]); + const model = createLanguageModel({ + OPENAI_API_KEY: "sk-test", + OPENAI_MODEL: "gpt-4", + }); + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.ok(capturedRequests[0].url.includes("/chat/completions")); + }); + + test("uses Responses API when OPENAI_ENDPOINT points to a /responses URL", async () => { + setupFetch([makeResponsesAPIResponse("OK")]); + const model = createLanguageModel({ + OPENAI_API_KEY: "sk-test", + OPENAI_MODEL: "gpt-4", + OPENAI_ENDPOINT: "https://api.openai.com/v1/responses", + }); + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.ok(capturedRequests[0].url.includes("/responses")); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok("input" in body, "Expected Responses API request format"); + }); + + test("OPENAI_ENDPOINT overrides default endpoint (Chat Completions path)", async () => { + setupFetch([makeChatCompletionsResponse("OK")]); + const customUrl = "https://proxy.example.com/v1/chat/completions"; + const model = createLanguageModel({ + OPENAI_API_KEY: "sk-test", + OPENAI_MODEL: "gpt-4", + OPENAI_ENDPOINT: customUrl, + }); + await model.complete("test"); + assert.equal(capturedRequests[0].url, customUrl); + }); + + test("OPENAI_ENDPOINT pointing to custom /responses URL uses Responses API", async () => { + setupFetch([makeResponsesAPIResponse("OK")]); + const customUrl = "https://proxy.example.com/v1/responses"; + const model = createLanguageModel({ + OPENAI_API_KEY: "sk-test", + OPENAI_MODEL: "gpt-4", + OPENAI_ENDPOINT: customUrl, + }); + await model.complete("test"); + assert.equal(capturedRequests[0].url, customUrl); + const body = JSON.parse(capturedRequests[0].options.body); + assert.ok("input" in body, "Expected Responses API request format"); + }); + + test("throws when OPENAI_API_KEY and AZURE_OPENAI_API_KEY are both missing", () => { + assert.throws(() => createLanguageModel({}), /Missing environment variable/); + }); +}); +