From 8a2d304f263013a8db278f5c6c883112871d31f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:35:35 +0000 Subject: [PATCH 1/6] Initial plan From e7b1e85f7ce5f43479bc9e421f6cf9a368811611 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:43:05 +0000 Subject: [PATCH 2/6] Add backward-compatible OpenAI Responses API support with tests Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- typescript/package.json | 1 + typescript/src/model.ts | 79 +++++++++- typescript/tests/model.test.mjs | 260 ++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 typescript/tests/model.test.mjs 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..626e4bfc 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. + * Set `OPENAI_USE_RESPONSES_API=true` to opt-in to the newer OpenAI Responses API + * (`https://api.openai.com/v1/responses`) instead of the default Chat Completions API. * * 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,12 @@ 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 ?? ""; + if (env.OPENAI_USE_RESPONSES_API === "true") { + const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/responses"; + return createOpenAIResponsesLanguageModel(apiKey, model, endPoint, org); + } + const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/chat/completions"; return createOpenAILanguageModel(apiKey, model, endPoint, org); } if (env.AZURE_OPENAI_API_KEY) { @@ -141,6 +147,24 @@ export function createAzureOpenAILanguageModel(apiKey: string, endPoint: string) return createFetchLanguageModel(endPoint, headers, {}); } +/** + * Creates a language model encapsulation of an OpenAI Responses API endpoint. + * This function uses the newer `/v1/responses` endpoint introduced by OpenAI. + * For users of the classic `/v1/chat/completions` endpoint, use `createOpenAILanguageModel` instead. + * @param apiKey The OpenAI API key. + * @param model The model name. + * @param endPoint The URL of the OpenAI Responses API endpoint. Defaults to "https://api.openai.com/v1/responses". + * @param org The OpenAI organization id. + * @returns An instance of `TypeChatLanguageModel`. + */ +export function createOpenAIResponsesLanguageModel(apiKey: string, model: string, endPoint = "https://api.openai.com/v1/responses", org = ""): TypeChatLanguageModel { + const headers = { + "Authorization": `Bearer ${apiKey}`, + "OpenAI-Organization": org + }; + return createResponsesFetchLanguageModel(endPoint, headers, { model }); +} + /** * Common OpenAI REST API endpoint encapsulation using the fetch API. */ @@ -187,6 +211,59 @@ function createFetchLanguageModel(url: string, headers: object, defaultParams: o } } +/** + * OpenAI Responses API endpoint encapsulation using the fetch API. + * Handles the different request/response format used by `/v1/responses`. + */ +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(retryPauseMs); + retryCount++; + } + } +} + /** * Returns true of the given HTTP status code represents a transient error. */ diff --git a/typescript/tests/model.test.mjs b/typescript/tests/model.test.mjs new file mode 100644 index 00000000..64e12e80 --- /dev/null +++ b/typescript/tests/model.test.mjs @@ -0,0 +1,260 @@ +/** + * 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, before, after } from "node:test"; +import assert from "node:assert/strict"; + +// Load the compiled module from dist +import { createOpenAILanguageModel, createOpenAIResponsesLanguageModel, createLanguageModel } from "../dist/index.js"; + +// --------------------------------------------------------------------------- +// Helpers: build mock Response objects +// --------------------------------------------------------------------------- + +function makeChatCompletionsResponse(content) { + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: "chatcmpl-123", + object: "chat.completion", + choices: [{ message: { role: "assistant", content } }], + }), + }; +} + +function makeResponsesAPIResponse(text) { + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: "resp-123", + object: "response", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + ], + }), + }; +} + +function makeErrorResponse(status, statusText) { + return { ok: false, status, statusText }; +} + +// --------------------------------------------------------------------------- +// 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")); + }); +}); + +// --------------------------------------------------------------------------- +// Responses API +// --------------------------------------------------------------------------- + +describe("createOpenAIResponsesLanguageModel (Responses API)", () => { + after(teardownFetch); + + test("uses /responses endpoint by default", async () => { + setupFetch([makeResponsesAPIResponse("Hello!")]); + const model = createOpenAIResponsesLanguageModel("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("/responses"), + "Expected /responses URL" + ); + }); + + test("sends input field (not messages) in request body", async () => { + setupFetch([makeResponsesAPIResponse("Hi!")]); + const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + 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 = createOpenAIResponsesLanguageModel("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 custom endpoint URL", async () => { + setupFetch([makeResponsesAPIResponse("Custom OK")]); + const customUrl = "https://custom.endpoint.com/v1/responses"; + const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4", customUrl); + await model.complete("test"); + assert.equal(capturedRequests[0].url, customUrl); + }); + + test("returns error on non-transient HTTP error", async () => { + setupFetch([makeErrorResponse(401, "Unauthorized")]); + const model = createOpenAIResponsesLanguageModel("invalid-key", "gpt-4"); + const result = await model.complete("test"); + assert.equal(result.success, false); + assert.ok(result.message.includes("401")); + }); + + test("returns error on unexpected response format", async () => { + setupFetch([{ + ok: true, + status: 200, + json: () => Promise.resolve({ output: [] }), + }]); + const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + const result = await model.complete("test"); + assert.equal(result.success, false); + assert.ok(result.message.includes("unexpected response format")); + }); +}); + +// --------------------------------------------------------------------------- +// createLanguageModel env-var routing +// --------------------------------------------------------------------------- + +describe("createLanguageModel environment variable routing", () => { + after(teardownFetch); + + test("defaults to Chat Completions API when OPENAI_USE_RESPONSES_API is not set", 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_USE_RESPONSES_API=true", async () => { + setupFetch([makeResponsesAPIResponse("OK")]); + const model = createLanguageModel({ + OPENAI_API_KEY: "sk-test", + OPENAI_MODEL: "gpt-4", + OPENAI_USE_RESPONSES_API: "true", + }); + const result = await model.complete("test"); + assert.equal(result.success, true); + assert.ok(capturedRequests[0].url.includes("/responses")); + }); + + 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 overrides default when Responses API is selected", 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_USE_RESPONSES_API: "true", + OPENAI_ENDPOINT: customUrl, + }); + await model.complete("test"); + assert.equal(capturedRequests[0].url, customUrl); + }); + + test("throws when OPENAI_API_KEY and AZURE_OPENAI_API_KEY are both missing", () => { + assert.throws(() => createLanguageModel({}), /Missing environment variable/); + }); +}); From 98b765b1c5ae52a86862aa7060a58a1d74d5daf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 17:47:29 +0000 Subject: [PATCH 3/6] Consolidate Responses API into createOpenAILanguageModel with URL auto-detection, retry-after support, and improved docs Agent-Logs-Url: https://github.com/microsoft/TypeChat/sessions/1e304792-c4f0-4d9a-8cff-8ae75788bc8a Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- typescript/src/model.ts | 112 +++++++++++++++++++++++++++----- typescript/tests/model.test.mjs | 85 +++++++++++++++++++++--- 2 files changed, 172 insertions(+), 25 deletions(-) diff --git a/typescript/src/model.ts b/typescript/src/model.ts index 626e4bfc..d8226f59 100644 --- a/typescript/src/model.ts +++ b/typescript/src/model.ts @@ -83,8 +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. - * Set `OPENAI_USE_RESPONSES_API=true` to opt-in to the newer OpenAI Responses API - * (`https://api.openai.com/v1/responses`) instead of the default Chat Completions API. + * 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 @@ -98,10 +98,6 @@ export function createLanguageModel(env: Record): Ty const apiKey = env.OPENAI_API_KEY ?? missingEnvironmentVariable("OPENAI_API_KEY"); const model = env.OPENAI_MODEL ?? missingEnvironmentVariable("OPENAI_MODEL"); const org = env.OPENAI_ORGANIZATION ?? ""; - if (env.OPENAI_USE_RESPONSES_API === "true") { - const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/responses"; - return createOpenAIResponsesLanguageModel(apiKey, model, endPoint, org); - } const endPoint = env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/chat/completions"; return createOpenAILanguageModel(apiKey, model, endPoint, org); } @@ -115,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 }); } @@ -149,11 +158,26 @@ export function createAzureOpenAILanguageModel(apiKey: string, endPoint: string) /** * Creates a language model encapsulation of an OpenAI Responses API endpoint. - * This function uses the newer `/v1/responses` endpoint introduced by OpenAI. - * For users of the classic `/v1/chat/completions` endpoint, use `createOpenAILanguageModel` instead. + * + * **Note:** Unlike the Chat Completions API (`/v1/chat/completions`), the Responses API + * (`/v1/responses`) uses a different request/response format: + * - **Request**: `input` field instead of `messages`. + * - **Response**: text is found at `output[n].content[m].text` where the output item has + * `type === "message"` and the content item has `type === "output_text"`. + * + * See the [OpenAI Responses API documentation](https://platform.openai.com/docs/api-reference/responses) + * for full details, and the [Azure OpenAI Responses API documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/responses) + * for Azure-specific endpoint formats. + * + * @deprecated Use {@link createOpenAILanguageModel} instead — it accepts the same parameters and + * automatically selects the Responses API when the endpoint URL path ends with `/responses`, or + * when the fifth argument `useResponsesApi` is `true`. + * This function will be removed in a future version. + * * @param apiKey The OpenAI API key. - * @param model The model name. - * @param endPoint The URL of the OpenAI Responses API endpoint. Defaults to "https://api.openai.com/v1/responses". + * @param model The model name (e.g. `"gpt-4o"`). + * @param endPoint The URL of the OpenAI Responses API endpoint. Defaults to + * `"https://api.openai.com/v1/responses"`. * @param org The OpenAI organization id. * @returns An instance of `TypeChatLanguageModel`. */ @@ -205,7 +229,7 @@ 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++; } } @@ -213,7 +237,29 @@ function createFetchLanguageModel(url: string, headers: object, defaultParams: o /** * OpenAI Responses API endpoint encapsulation using the fetch API. - * Handles the different request/response format used by `/v1/responses`. + * + * 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 = { @@ -258,12 +304,31 @@ function createResponsesFetchLanguageModel(url: string, headers: object, default 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++; } } } +/** + * Returns the number of milliseconds to wait before the next retry attempt. + * For 429 (Too Many Requests) responses, the `Retry-After` header value (in seconds) is used + * when present, capped at `maxMs` to avoid waiting longer than the configured total budget. + * For all other transient errors the default pause is returned. + */ +function getRetryDelayMs(response: Response, defaultMs: number, maxMs: number): number { + if (response.status === 429) { + 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. */ @@ -279,6 +344,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 index 64e12e80..15486824 100644 --- a/typescript/tests/model.test.mjs +++ b/typescript/tests/model.test.mjs @@ -5,7 +5,7 @@ * These tests use mocked fetch to avoid requiring real API keys. */ -import { test, describe, before, after } from "node:test"; +import { test, describe, after } from "node:test"; import assert from "node:assert/strict"; // Load the compiled module from dist @@ -19,6 +19,7 @@ function makeChatCompletionsResponse(content) { return { ok: true, status: 200, + headers: { get: (_name) => null }, json: () => Promise.resolve({ id: "chatcmpl-123", @@ -32,6 +33,7 @@ function makeResponsesAPIResponse(text) { return { ok: true, status: 200, + headers: { get: (_name) => null }, json: () => Promise.resolve({ id: "resp-123", @@ -47,8 +49,18 @@ function makeResponsesAPIResponse(text) { }; } -function makeErrorResponse(status, statusText) { - return { ok: false, status, statusText }; +function makeErrorResponse(status, statusText, retryAfterSec = null) { + return { + ok: false, + status, + statusText, + headers: { + get: (name) => + name.toLowerCase() === "retry-after" && retryAfterSec !== null + ? String(retryAfterSec) + : null, + }, + }; } // --------------------------------------------------------------------------- @@ -132,13 +144,49 @@ describe("createOpenAILanguageModel (Chat Completions API)", () => { 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 for Chat Completions API", 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"); + }); }); // --------------------------------------------------------------------------- // Responses API // --------------------------------------------------------------------------- -describe("createOpenAIResponsesLanguageModel (Responses API)", () => { +describe("createOpenAIResponsesLanguageModel (Responses API, deprecated)", () => { after(teardownFetch); test("uses /responses endpoint by default", async () => { @@ -190,6 +238,7 @@ describe("createOpenAIResponsesLanguageModel (Responses API)", () => { setupFetch([{ ok: true, status: 200, + headers: { get: (_name) => null }, json: () => Promise.resolve({ output: [] }), }]); const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); @@ -197,6 +246,20 @@ describe("createOpenAIResponsesLanguageModel (Responses API)", () => { assert.equal(result.success, false); assert.ok(result.message.includes("unexpected response format")); }); + + test("respects retry-after header on 429 for Responses API", async () => { + setupFetch([ + makeErrorResponse(429, "Too Many Requests", 0), // Retry-After: 0s (immediate) + makeResponsesAPIResponse("OK after retry"), + ]); + const model = createOpenAIResponsesLanguageModel("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"); + }); }); // --------------------------------------------------------------------------- @@ -206,7 +269,7 @@ describe("createOpenAIResponsesLanguageModel (Responses API)", () => { describe("createLanguageModel environment variable routing", () => { after(teardownFetch); - test("defaults to Chat Completions API when OPENAI_USE_RESPONSES_API is not set", async () => { + test("defaults to Chat Completions API", async () => { setupFetch([makeChatCompletionsResponse("OK")]); const model = createLanguageModel({ OPENAI_API_KEY: "sk-test", @@ -217,16 +280,18 @@ describe("createLanguageModel environment variable routing", () => { assert.ok(capturedRequests[0].url.includes("/chat/completions")); }); - test("uses Responses API when OPENAI_USE_RESPONSES_API=true", async () => { + 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_USE_RESPONSES_API: "true", + 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 () => { @@ -241,20 +306,22 @@ describe("createLanguageModel environment variable routing", () => { assert.equal(capturedRequests[0].url, customUrl); }); - test("OPENAI_ENDPOINT overrides default when Responses API is selected", async () => { + 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_USE_RESPONSES_API: "true", 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/); }); }); + From ca42ac166637152f6f0ace14a373dafe3ce3f86b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 17:48:51 +0000 Subject: [PATCH 4/6] Add parentheses around useResponsesApi nullish coalescing expression for clarity Agent-Logs-Url: https://github.com/microsoft/TypeChat/sessions/1e304792-c4f0-4d9a-8cff-8ae75788bc8a Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- typescript/src/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/model.ts b/typescript/src/model.ts index d8226f59..6551dc1a 100644 --- a/typescript/src/model.ts +++ b/typescript/src/model.ts @@ -132,7 +132,7 @@ export function createOpenAILanguageModel(apiKey: string, model: string, endPoin "Authorization": `Bearer ${apiKey}`, "OpenAI-Organization": org }; - if (useResponsesApi ?? isResponsesApiUrl(endPoint)) { + if ((useResponsesApi ?? isResponsesApiUrl(endPoint))) { return createResponsesFetchLanguageModel(endPoint, headers, { model }); } return createFetchLanguageModel(endPoint, headers, { model }); From 47656c46e94bf736c3cc9747353b01016b961c98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 18:20:50 +0000 Subject: [PATCH 5/6] Remove createOpenAIResponsesLanguageModel; widen Retry-After handling to all transient errors Agent-Logs-Url: https://github.com/microsoft/TypeChat/sessions/a10ca0ce-110b-4b44-b6d2-5a64387a9dd2 Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- typescript/src/model.ts | 50 +++++---------------------- typescript/tests/model.test.mjs | 60 +++++++++++++-------------------- 2 files changed, 31 insertions(+), 79 deletions(-) diff --git a/typescript/src/model.ts b/typescript/src/model.ts index 6551dc1a..46f1cb95 100644 --- a/typescript/src/model.ts +++ b/typescript/src/model.ts @@ -156,39 +156,6 @@ export function createAzureOpenAILanguageModel(apiKey: string, endPoint: string) return createFetchLanguageModel(endPoint, headers, {}); } -/** - * Creates a language model encapsulation of an OpenAI Responses API endpoint. - * - * **Note:** Unlike the Chat Completions API (`/v1/chat/completions`), the Responses API - * (`/v1/responses`) uses a different request/response format: - * - **Request**: `input` field instead of `messages`. - * - **Response**: text is found at `output[n].content[m].text` where the output item has - * `type === "message"` and the content item has `type === "output_text"`. - * - * See the [OpenAI Responses API documentation](https://platform.openai.com/docs/api-reference/responses) - * for full details, and the [Azure OpenAI Responses API documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/responses) - * for Azure-specific endpoint formats. - * - * @deprecated Use {@link createOpenAILanguageModel} instead — it accepts the same parameters and - * automatically selects the Responses API when the endpoint URL path ends with `/responses`, or - * when the fifth argument `useResponsesApi` is `true`. - * This function will be removed in a future version. - * - * @param apiKey The OpenAI API key. - * @param model The model name (e.g. `"gpt-4o"`). - * @param endPoint The URL of the OpenAI Responses API endpoint. Defaults to - * `"https://api.openai.com/v1/responses"`. - * @param org The OpenAI organization id. - * @returns An instance of `TypeChatLanguageModel`. - */ -export function createOpenAIResponsesLanguageModel(apiKey: string, model: string, endPoint = "https://api.openai.com/v1/responses", org = ""): TypeChatLanguageModel { - const headers = { - "Authorization": `Bearer ${apiKey}`, - "OpenAI-Organization": org - }; - return createResponsesFetchLanguageModel(endPoint, headers, { model }); -} - /** * Common OpenAI REST API endpoint encapsulation using the fetch API. */ @@ -312,18 +279,17 @@ function createResponsesFetchLanguageModel(url: string, headers: object, default /** * Returns the number of milliseconds to wait before the next retry attempt. - * For 429 (Too Many Requests) responses, the `Retry-After` header value (in seconds) is used - * when present, capped at `maxMs` to avoid waiting longer than the configured total budget. + * 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 { - if (response.status === 429) { - const retryAfter = response.headers.get("retry-after"); - if (retryAfter) { - const seconds = parseInt(retryAfter, 10); - if (!isNaN(seconds)) { - return Math.min(seconds * 1000, maxMs); - } + 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; diff --git a/typescript/tests/model.test.mjs b/typescript/tests/model.test.mjs index 15486824..42b38aad 100644 --- a/typescript/tests/model.test.mjs +++ b/typescript/tests/model.test.mjs @@ -9,7 +9,7 @@ import { test, describe, after } from "node:test"; import assert from "node:assert/strict"; // Load the compiled module from dist -import { createOpenAILanguageModel, createOpenAIResponsesLanguageModel, createLanguageModel } from "../dist/index.js"; +import { createOpenAILanguageModel, createLanguageModel } from "../dist/index.js"; // --------------------------------------------------------------------------- // Helpers: build mock Response objects @@ -167,7 +167,7 @@ describe("createOpenAILanguageModel (Chat Completions API)", () => { assert.ok("input" in body, "Expected input field when useResponsesApi=true"); }); - test("respects retry-after header on 429 for Chat Completions API", async () => { + test("respects retry-after header on 429", async () => { setupFetch([ makeErrorResponse(429, "Too Many Requests", 0), // Retry-After: 0s (immediate) makeChatCompletionsResponse("OK after retry"), @@ -180,30 +180,32 @@ describe("createOpenAILanguageModel (Chat Completions API)", () => { 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 +// Responses API via createOpenAILanguageModel // --------------------------------------------------------------------------- -describe("createOpenAIResponsesLanguageModel (Responses API, deprecated)", () => { +describe("createOpenAILanguageModel (Responses API path)", () => { after(teardownFetch); - test("uses /responses endpoint by default", async () => { - setupFetch([makeResponsesAPIResponse("Hello!")]); - const model = createOpenAIResponsesLanguageModel("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("/responses"), - "Expected /responses URL" - ); - }); - test("sends input field (not messages) in request body", async () => { setupFetch([makeResponsesAPIResponse("Hi!")]); - const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + 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"); @@ -212,28 +214,12 @@ describe("createOpenAIResponsesLanguageModel (Responses API, deprecated)", () => test("parses text from output[0].content[0].text", async () => { setupFetch([makeResponsesAPIResponse("The answer is 42.")]); - const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + 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("accepts custom endpoint URL", async () => { - setupFetch([makeResponsesAPIResponse("Custom OK")]); - const customUrl = "https://custom.endpoint.com/v1/responses"; - const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4", customUrl); - await model.complete("test"); - assert.equal(capturedRequests[0].url, customUrl); - }); - - test("returns error on non-transient HTTP error", async () => { - setupFetch([makeErrorResponse(401, "Unauthorized")]); - const model = createOpenAIResponsesLanguageModel("invalid-key", "gpt-4"); - const result = await model.complete("test"); - assert.equal(result.success, false); - assert.ok(result.message.includes("401")); - }); - test("returns error on unexpected response format", async () => { setupFetch([{ ok: true, @@ -241,18 +227,18 @@ describe("createOpenAIResponsesLanguageModel (Responses API, deprecated)", () => headers: { get: (_name) => null }, json: () => Promise.resolve({ output: [] }), }]); - const model = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + 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", async () => { + 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 = createOpenAIResponsesLanguageModel("sk-test", "gpt-4"); + 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"); From 8bc834625198019b985f7d72df4ed9c40af7e7d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 18:22:05 +0000 Subject: [PATCH 6/6] Add 503 Retry-After test for Responses API path Agent-Logs-Url: https://github.com/microsoft/TypeChat/sessions/a10ca0ce-110b-4b44-b6d2-5a64387a9dd2 Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- typescript/tests/model.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/typescript/tests/model.test.mjs b/typescript/tests/model.test.mjs index 42b38aad..a692fa19 100644 --- a/typescript/tests/model.test.mjs +++ b/typescript/tests/model.test.mjs @@ -246,6 +246,20 @@ describe("createOpenAILanguageModel (Responses API path)", () => { 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"); + }); }); // ---------------------------------------------------------------------------