feat: support multiple providers via Responses-Completion transformation (#247)

https://github.com/user-attachments/assets/9ecb51be-fa65-4e99-8512-abb898dda569

Implemented it as a transformation between Responses API and Completion
API so that it supports existing providers that implement the Completion
API and minimizes the changes needed to the codex repo.

---------

Co-authored-by: Thibault Sottiaux <tibo@openai.com>
Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com>
Co-authored-by: Fouad Matin <fouad@openai.com>
This commit is contained in:
Daniel Nakov
2025-04-20 23:59:34 -04:00
committed by GitHub
parent 693bd59ecc
commit eafbc75612
11 changed files with 1870 additions and 83 deletions

View File

@@ -19,15 +19,13 @@ import { ReviewDecision } from "./utils/agent/review";
import { AutoApprovalMode } from "./utils/auto-approval-mode";
import { checkForUpdates } from "./utils/check-updates";
import {
getApiKey,
loadConfig,
PRETTY_PRINT,
INSTRUCTIONS_FILEPATH,
} from "./utils/config";
import { createInputItem } from "./utils/input-utils";
import {
isModelSupportedForResponses,
preloadModels,
} from "./utils/model-utils.js";
import { isModelSupportedForResponses } from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { onExit, setInkRenderer } from "./utils/terminal";
import chalk from "chalk";
@@ -97,6 +95,7 @@ const cli = meow(
help: { type: "boolean", aliases: ["h"] },
view: { type: "string" },
model: { type: "string", aliases: ["m"] },
provider: { type: "string", aliases: ["p"] },
image: { type: "string", isMultiple: true, aliases: ["i"] },
quiet: {
type: "boolean",
@@ -227,7 +226,19 @@ if (cli.flags.config) {
// API key handling
// ---------------------------------------------------------------------------
const apiKey = process.env["OPENAI_API_KEY"];
const fullContextMode = Boolean(cli.flags.fullContext);
let config = loadConfig(undefined, undefined, {
cwd: process.cwd(),
disableProjectDoc: Boolean(cli.flags.noProjectDoc),
projectDocPath: cli.flags.projectDoc as string | undefined,
isFullContext: fullContextMode,
});
const prompt = cli.input[0];
const model = cli.flags.model ?? config.model;
const imagePaths = cli.flags.image as Array<string> | undefined;
const provider = cli.flags.provider ?? config.provider;
const apiKey = getApiKey(provider);
if (!apiKey) {
// eslint-disable-next-line no-console
@@ -242,24 +253,13 @@ if (!apiKey) {
process.exit(1);
}
const fullContextMode = Boolean(cli.flags.fullContext);
let config = loadConfig(undefined, undefined, {
cwd: process.cwd(),
disableProjectDoc: Boolean(cli.flags.noProjectDoc),
projectDocPath: cli.flags.projectDoc as string | undefined,
isFullContext: fullContextMode,
});
const prompt = cli.input[0];
const model = cli.flags.model;
const imagePaths = cli.flags.image as Array<string> | undefined;
config = {
apiKey,
...config,
model: model ?? config.model,
flexMode: Boolean(cli.flags.flexMode),
notify: Boolean(cli.flags.notify),
flexMode: Boolean(cli.flags.flexMode),
provider,
};
// Check for updates after loading config
@@ -281,7 +281,10 @@ if (cli.flags.flexMode) {
}
}
if (!(await isModelSupportedForResponses(config.model))) {
if (
!(await isModelSupportedForResponses(config.model)) &&
(!provider || provider.toLowerCase() === "openai")
) {
// eslint-disable-next-line no-console
console.error(
`The model "${config.model}" does not appear in the list of models ` +
@@ -378,8 +381,6 @@ const approvalPolicy: ApprovalPolicy =
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
preloadModels();
const instance = render(
<App
prompt={prompt}

View File

@@ -18,7 +18,7 @@ import { AgentLoop } from "../../utils/agent/agent-loop.js";
import { log } from "../../utils/agent/log.js";
import { ReviewDecision } from "../../utils/agent/review.js";
import { generateCompactSummary } from "../../utils/compact-summary.js";
import { OPENAI_BASE_URL } from "../../utils/config.js";
import { OPENAI_BASE_URL, saveConfig } from "../../utils/config.js";
import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/extract-applied-patches.js";
import { getGitDiff } from "../../utils/get-diff.js";
import { createInputItem } from "../../utils/input-utils.js";
@@ -144,6 +144,7 @@ export default function TerminalChat({
// Desktop notification setting
const notify = config.notify;
const [model, setModel] = useState<string>(config.model);
const [provider, setProvider] = useState<string>(config.provider || "openai");
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
const [items, setItems] = useState<Array<ResponseItem>>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -228,7 +229,7 @@ export default function TerminalChat({
log("creating NEW AgentLoop");
log(
`model=${model} instructions=${Boolean(
`model=${model} provider=${provider} instructions=${Boolean(
config.instructions,
)} approvalPolicy=${approvalPolicy}`,
);
@@ -238,6 +239,7 @@ export default function TerminalChat({
agentRef.current = new AgentLoop({
model,
provider,
config,
instructions: config.instructions,
approvalPolicy,
@@ -307,10 +309,15 @@ export default function TerminalChat({
agentRef.current = undefined;
forceUpdate(); // rerender after teardown too
};
// We intentionally omit 'approvalPolicy' and 'confirmationPrompt' from the deps
// so switching modes or showing confirmation dialogs doesnt tear down the loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, config, requestConfirmation, additionalWritableRoots]);
}, [
model,
provider,
config,
approvalPolicy,
confirmationPrompt,
requestConfirmation,
additionalWritableRoots,
]);
// whenever loading starts/stops, reset or start a timer — but pause the
// timer while a confirmation overlay is displayed so we don't trigger a
@@ -417,7 +424,7 @@ export default function TerminalChat({
// ────────────────────────────────────────────────────────────────
useEffect(() => {
(async () => {
const available = await getAvailableModels();
const available = await getAvailableModels(provider);
if (model && available.length > 0 && !available.includes(model)) {
setItems((prev) => [
...prev,
@@ -428,7 +435,7 @@ export default function TerminalChat({
content: [
{
type: "input_text",
text: `Warning: model "${model}" is not in the list of available models returned by OpenAI.`,
text: `Warning: model "${model}" is not in the list of available models for provider "${provider}".`,
},
],
},
@@ -470,6 +477,7 @@ export default function TerminalChat({
version: CLI_VERSION,
PWD,
model,
provider,
approvalPolicy,
colorsByPolicy,
agent,
@@ -566,6 +574,7 @@ export default function TerminalChat({
{overlayMode === "model" && (
<ModelOverlay
currentModel={model}
currentProvider={provider}
hasLastResponse={Boolean(lastResponseId)}
onSelect={(newModel) => {
log(
@@ -582,6 +591,13 @@ export default function TerminalChat({
prev && newModel !== model ? null : prev,
);
// Save model to config
saveConfig({
...config,
model: newModel,
provider: provider,
});
setItems((prev) => [
...prev,
{
@@ -599,6 +615,51 @@ export default function TerminalChat({
setOverlayMode("none");
}}
onSelectProvider={(newProvider) => {
log(
"TerminalChat: interruptAgent invoked calling agent.cancel()",
);
if (!agent) {
log("TerminalChat: agent is not ready yet");
}
agent?.cancel();
setLoading(false);
// Select default model for the new provider
const defaultModel = model;
// Save provider to config
const updatedConfig = {
...config,
provider: newProvider,
model: defaultModel,
};
saveConfig(updatedConfig);
setProvider(newProvider);
setModel(defaultModel);
setLastResponseId((prev) =>
prev && newProvider !== provider ? null : prev,
);
setItems((prev) => [
...prev,
{
id: `switch-provider-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: `Switched provider to ${newProvider} with model ${defaultModel}`,
},
],
},
]);
// Don't close the overlay so user can select a model for the new provider
// setOverlayMode("none");
}}
onExit={() => setOverlayMode("none")}
/>
)}

View File

@@ -9,6 +9,7 @@ export interface TerminalHeaderProps {
version: string;
PWD: string;
model: string;
provider?: string;
approvalPolicy: string;
colorsByPolicy: Record<string, string | undefined>;
agent?: AgentLoop;
@@ -21,6 +22,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
version,
PWD,
model,
provider = "openai",
approvalPolicy,
colorsByPolicy,
agent,
@@ -32,7 +34,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
{terminalRows < 10 ? (
// Compact header for small terminal windows
<Text>
Codex v{version} {PWD} {model} {" "}
Codex v{version} {PWD} {model} ({provider}) {" "}
<Text color={colorsByPolicy[approvalPolicy]}>{approvalPolicy}</Text>
{flexModeEnabled ? " flex-mode" : ""}
</Text>
@@ -65,6 +67,10 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
<Text dimColor>
<Text color="blueBright"></Text> model: <Text bold>{model}</Text>
</Text>
<Text dimColor>
<Text color="blueBright"></Text> provider:{" "}
<Text bold>{provider}</Text>
</Text>
<Text dimColor>
<Text color="blueBright"></Text> approval:{" "}
<Text bold color={colorsByPolicy[approvalPolicy]} dimColor>

View File

@@ -1,8 +1,9 @@
import TypeaheadOverlay from "./typeahead-overlay.js";
import {
getAvailableModels,
RECOMMENDED_MODELS,
RECOMMENDED_MODELS as _RECOMMENDED_MODELS,
} from "../utils/model-utils.js";
import { providers } from "../utils/providers.js";
import { Box, Text, useInput } from "ink";
import React, { useEffect, useState } from "react";
@@ -16,39 +17,51 @@ import React, { useEffect, useState } from "react";
*/
type Props = {
currentModel: string;
currentProvider?: string;
hasLastResponse: boolean;
onSelect: (model: string) => void;
onSelectProvider?: (provider: string) => void;
onExit: () => void;
};
export default function ModelOverlay({
currentModel,
currentProvider = "openai",
hasLastResponse,
onSelect,
onSelectProvider,
onExit,
}: Props): JSX.Element {
const [items, setItems] = useState<Array<{ label: string; value: string }>>(
[],
);
const [providerItems, _setProviderItems] = useState<
Array<{ label: string; value: string }>
>(Object.values(providers).map((p) => ({ label: p.name, value: p.name })));
const [mode, setMode] = useState<"model" | "provider">("model");
const [isLoading, setIsLoading] = useState<boolean>(true);
// This effect will run when the provider changes to update the model list
useEffect(() => {
setIsLoading(true);
(async () => {
const models = await getAvailableModels();
// Split the list into recommended and “other” models.
const recommended = RECOMMENDED_MODELS.filter((m) => models.includes(m));
const others = models.filter((m) => !recommended.includes(m));
const ordered = [...recommended, ...others.sort()];
setItems(
ordered.map((m) => ({
label: recommended.includes(m) ? `${m}` : m,
value: m,
})),
);
try {
const models = await getAvailableModels(currentProvider);
// Convert the models to the format needed by TypeaheadOverlay
setItems(
models.map((m) => ({
label: m,
value: m,
})),
);
} catch (error) {
// Silently handle errors - remove console.error
// console.error("Error loading models:", error);
} finally {
setIsLoading(false);
}
})();
}, []);
}, [currentProvider]);
// ---------------------------------------------------------------------------
// If the conversation already contains a response we cannot change the model
@@ -58,10 +71,14 @@ export default function ModelOverlay({
// available action is to dismiss the overlay (Esc or Enter).
// ---------------------------------------------------------------------------
// Always register input handling so hooks are called consistently.
// Register input handling for switching between model and provider selection
useInput((_input, key) => {
if (hasLastResponse && (key.escape || key.return)) {
onExit();
} else if (!hasLastResponse) {
if (key.tab) {
setMode(mode === "model" ? "provider" : "model");
}
}
});
@@ -91,13 +108,47 @@ export default function ModelOverlay({
);
}
if (mode === "provider") {
return (
<TypeaheadOverlay
title="Select provider"
description={
<Box flexDirection="column">
<Text>
Current provider:{" "}
<Text color="greenBright">{currentProvider}</Text>
</Text>
<Text dimColor>press tab to switch to model selection</Text>
</Box>
}
initialItems={providerItems}
currentValue={currentProvider}
onSelect={(provider) => {
if (onSelectProvider) {
onSelectProvider(provider);
// Immediately switch to model selection so user can pick a model for the new provider
setMode("model");
}
}}
onExit={onExit}
/>
);
}
return (
<TypeaheadOverlay
title="Switch model"
title="Select model"
description={
<Text>
Current model: <Text color="greenBright">{currentModel}</Text>
</Text>
<Box flexDirection="column">
<Text>
Current model: <Text color="greenBright">{currentModel}</Text>
</Text>
<Text>
Current provider: <Text color="greenBright">{currentProvider}</Text>
</Text>
{isLoading && <Text color="yellow">Loading models...</Text>}
<Text dimColor>press tab to switch to provider selection</Text>
</Box>
}
initialItems={items}
currentValue={currentModel}

View File

@@ -5,7 +5,12 @@ import type { FileOperation } from "../utils/singlepass/file_ops";
import Spinner from "./vendor/ink-spinner"; // Thirdparty / vendor components
import TextInput from "./vendor/ink-text-input";
import { OPENAI_TIMEOUT_MS, OPENAI_BASE_URL } from "../utils/config";
import {
OPENAI_TIMEOUT_MS,
OPENAI_BASE_URL as _OPENAI_BASE_URL,
getBaseUrl,
getApiKey,
} from "../utils/config";
import {
generateDiffSummary,
generateEditSummary,
@@ -394,8 +399,8 @@ export function SinglePassApp({
});
const openai = new OpenAI({
apiKey: config.apiKey ?? "",
baseURL: OPENAI_BASE_URL || undefined,
apiKey: getApiKey(config.provider),
baseURL: getBaseUrl(config.provider),
timeout: OPENAI_TIMEOUT_MS,
});
const chatResp = await openai.beta.chat.completions.parse({

View File

@@ -1,16 +1,19 @@
import type { ReviewDecision } from "./review.js";
import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js";
import type { AppConfig } from "../config.js";
import type { ResponseEvent } from "../responses.js";
import type {
ResponseFunctionToolCall,
ResponseInputItem,
ResponseItem,
ResponseCreateParams,
} from "openai/resources/responses/responses.mjs";
import type { Reasoning } from "openai/resources.mjs";
import { log } from "./log.js";
import { OPENAI_BASE_URL, OPENAI_TIMEOUT_MS } from "../config.js";
import { OPENAI_TIMEOUT_MS, getApiKey, getBaseUrl } from "../config.js";
import { parseToolCallArguments } from "../parsers.js";
import { responsesCreateViaChatCompletions } from "../responses.js";
import {
ORIGIN,
CLI_VERSION,
@@ -39,6 +42,7 @@ const alreadyProcessedResponses = new Set();
type AgentLoopParams = {
model: string;
provider?: string;
config?: AppConfig;
instructions?: string;
approvalPolicy: ApprovalPolicy;
@@ -58,6 +62,7 @@ type AgentLoopParams = {
export class AgentLoop {
private model: string;
private provider: string;
private instructions?: string;
private approvalPolicy: ApprovalPolicy;
private config: AppConfig;
@@ -198,6 +203,7 @@ export class AgentLoop {
// private cumulativeThinkingMs = 0;
constructor({
model,
provider = "openai",
instructions,
approvalPolicy,
// `config` used to be required. Some unittests (and potentially other
@@ -214,6 +220,7 @@ export class AgentLoop {
additionalWritableRoots,
}: AgentLoopParams & { config?: AppConfig }) {
this.model = model;
this.provider = provider;
this.instructions = instructions;
this.approvalPolicy = approvalPolicy;
@@ -236,7 +243,9 @@ export class AgentLoop {
this.sessionId = getSessionId() || randomUUID().replaceAll("-", "");
// Configure OpenAI client with optional timeout (ms) from environment
const timeoutMs = OPENAI_TIMEOUT_MS;
const apiKey = this.config.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
const apiKey = getApiKey(this.provider);
const baseURL = getBaseUrl(this.provider);
this.oai = new OpenAI({
// The OpenAI JS SDK only requires `apiKey` when making requests against
// the official API. When running unittests we stub out all network
@@ -245,7 +254,7 @@ export class AgentLoop {
// errors inside the SDK (it validates that `apiKey` is a nonempty
// string when the field is present).
...(apiKey ? { apiKey } : {}),
baseURL: OPENAI_BASE_URL,
baseURL,
defaultHeaders: {
originator: ORIGIN,
version: CLI_VERSION,
@@ -492,11 +501,23 @@ export class AgentLoop {
const mergedInstructions = [prefix, this.instructions]
.filter(Boolean)
.join("\n");
const responseCall =
!this.config.provider ||
this.config.provider?.toLowerCase() === "openai"
? (params: ResponseCreateParams) =>
this.oai.responses.create(params)
: (params: ResponseCreateParams) =>
responsesCreateViaChatCompletions(
this.oai,
params as ResponseCreateParams & { stream: true },
);
log(
`instructions (length ${mergedInstructions.length}): ${mergedInstructions}`,
);
// eslint-disable-next-line no-await-in-loop
stream = await this.oai.responses.create({
stream = await responseCall({
model: this.model,
instructions: mergedInstructions,
previous_response_id: lastResponseId || undefined,
@@ -720,7 +741,7 @@ export class AgentLoop {
try {
// eslint-disable-next-line no-await-in-loop
for await (const event of stream) {
for await (const event of stream as AsyncIterable<ResponseEvent>) {
log(`AgentLoop.run(): response event ${event.type}`);
// process and surface each item (noop until we can depend on streaming events)

View File

@@ -10,6 +10,7 @@ import type { FullAutoErrorMode } from "./auto-approval-mode.js";
import { log } from "./agent/log.js";
import { AutoApprovalMode } from "./auto-approval-mode.js";
import { providers } from "./providers.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
import { homedir } from "os";
@@ -40,12 +41,33 @@ export function setApiKey(apiKey: string): void {
OPENAI_API_KEY = apiKey;
}
export function getBaseUrl(provider: string = "openai"): string | undefined {
const providerInfo = providers[provider.toLowerCase()];
if (providerInfo) {
return providerInfo.baseURL;
}
return undefined;
}
export function getApiKey(provider: string = "openai"): string | undefined {
const providerInfo = providers[provider.toLowerCase()];
if (providerInfo) {
if (providerInfo.name === "Ollama") {
return process.env[providerInfo.envKey] ?? "dummy";
}
return process.env[providerInfo.envKey];
}
return undefined;
}
// Formatting (quiet mode-only).
export const PRETTY_PRINT = Boolean(process.env["PRETTY_PRINT"] || "");
// Represents config as persisted in config.json.
export type StoredConfig = {
model?: string;
provider?: string;
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
@@ -76,6 +98,7 @@ export type MemoryConfig = {
export type AppConfig = {
apiKey?: string;
model: string;
provider?: string;
instructions: string;
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
@@ -270,6 +293,7 @@ export const loadConfig = (
(options.isFullContext
? DEFAULT_FULL_CONTEXT_MODEL
: DEFAULT_AGENTIC_MODEL),
provider: storedConfig.provider,
instructions: combinedInstructions,
notify: storedConfig.notify === true,
approvalMode: storedConfig.approvalMode,
@@ -389,6 +413,7 @@ export const saveConfig = (
// Create the config object to save
const configToSave: StoredConfig = {
model: config.model,
provider: config.provider,
approvalMode: config.approvalMode,
};

View File

@@ -1,4 +1,4 @@
import { OPENAI_API_KEY } from "./config";
import { getBaseUrl, getApiKey } from "./config";
import OpenAI from "openai";
const MODEL_LIST_TIMEOUT_MS = 2_000; // 2 seconds
@@ -12,44 +12,38 @@ export const RECOMMENDED_MODELS: Array<string> = ["o4-mini", "o3"];
* lifetime of the process and the results are cached for subsequent calls.
*/
let modelsPromise: Promise<Array<string>> | null = null;
async function fetchModels(): Promise<Array<string>> {
async function fetchModels(provider: string): Promise<Array<string>> {
// If the user has not configured an API key we cannot hit the network.
if (!OPENAI_API_KEY) {
return RECOMMENDED_MODELS;
if (!getApiKey(provider)) {
throw new Error("No API key configured for provider: " + provider);
}
const baseURL = getBaseUrl(provider);
try {
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
const openai = new OpenAI({ apiKey: getApiKey(provider), baseURL });
const list = await openai.models.list();
const models: Array<string> = [];
for await (const model of list as AsyncIterable<{ id?: string }>) {
if (model && typeof model.id === "string") {
models.push(model.id);
let modelStr = model.id;
// fix for gemini
if (modelStr.startsWith("models/")) {
modelStr = modelStr.replace("models/", "");
}
models.push(modelStr);
}
}
return models.sort();
} catch {
} catch (error) {
return [];
}
}
export function preloadModels(): void {
if (!modelsPromise) {
// Fireandforget callers that truly need the list should `await`
// `getAvailableModels()` instead.
void getAvailableModels();
}
}
export async function getAvailableModels(): Promise<Array<string>> {
if (!modelsPromise) {
modelsPromise = fetchModels();
}
return modelsPromise;
export async function getAvailableModels(
provider: string,
): Promise<Array<string>> {
return fetchModels(provider.toLowerCase());
}
/**
@@ -70,7 +64,7 @@ export async function isModelSupportedForResponses(
try {
const models = await Promise.race<Array<string>>([
getAvailableModels(),
getAvailableModels("openai"),
new Promise<Array<string>>((resolve) =>
setTimeout(() => resolve([]), MODEL_LIST_TIMEOUT_MS),
),

View File

@@ -0,0 +1,45 @@
export const providers: Record<
string,
{ name: string; baseURL: string; envKey: string }
> = {
openai: {
name: "OpenAI",
baseURL: "https://api.openai.com/v1",
envKey: "OPENAI_API_KEY",
},
openrouter: {
name: "OpenRouter",
baseURL: "https://openrouter.ai/api/v1",
envKey: "OPENROUTER_API_KEY",
},
gemini: {
name: "Gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
envKey: "GEMINI_API_KEY",
},
ollama: {
name: "Ollama",
baseURL: "http://localhost:11434/v1",
envKey: "OLLAMA_API_KEY",
},
mistral: {
name: "Mistral",
baseURL: "https://api.mistral.ai/v1",
envKey: "MISTRAL_API_KEY",
},
deepseek: {
name: "DeepSeek",
baseURL: "https://api.deepseek.com",
envKey: "DEEPSEEK_API_KEY",
},
xai: {
name: "xAI",
baseURL: "https://api.x.ai/v1",
envKey: "XAI_API_KEY",
},
groq: {
name: "Groq",
baseURL: "https://api.groq.com/openai/v1",
envKey: "GROQ_API_KEY",
},
};

View File

@@ -0,0 +1,736 @@
import type { OpenAI } from "openai";
import type {
ResponseCreateParams,
Response,
} from "openai/resources/responses/responses";
// Define interfaces based on OpenAI API documentation
type ResponseCreateInput = ResponseCreateParams;
type ResponseOutput = Response;
// interface ResponseOutput {
// id: string;
// object: 'response';
// created_at: number;
// status: 'completed' | 'failed' | 'in_progress' | 'incomplete';
// error: { code: string; message: string } | null;
// incomplete_details: { reason: string } | null;
// instructions: string | null;
// max_output_tokens: number | null;
// model: string;
// output: Array<{
// type: 'message';
// id: string;
// status: 'completed' | 'in_progress';
// role: 'assistant';
// content: Array<{
// type: 'output_text' | 'function_call';
// text?: string;
// annotations?: Array<any>;
// tool_call?: {
// id: string;
// type: 'function';
// function: { name: string; arguments: string };
// };
// }>;
// }>;
// parallel_tool_calls: boolean;
// previous_response_id: string | null;
// reasoning: { effort: string | null; summary: string | null };
// store: boolean;
// temperature: number;
// text: { format: { type: 'text' } };
// tool_choice: string | object;
// tools: Array<any>;
// top_p: number;
// truncation: string;
// usage: {
// input_tokens: number;
// input_tokens_details: { cached_tokens: number };
// output_tokens: number;
// output_tokens_details: { reasoning_tokens: number };
// total_tokens: number;
// } | null;
// user: string | null;
// metadata: Record<string, string>;
// }
// Define types for the ResponseItem content and parts
type ResponseContentPart = {
type: string;
[key: string]: unknown;
};
type ResponseItemType = {
type: string;
id?: string;
status?: string;
role?: string;
content?: Array<ResponseContentPart>;
[key: string]: unknown;
};
type ResponseEvent =
| { type: "response.created"; response: Partial<ResponseOutput> }
| { type: "response.in_progress"; response: Partial<ResponseOutput> }
| {
type: "response.output_item.added";
output_index: number;
item: ResponseItemType;
}
| {
type: "response.content_part.added";
item_id: string;
output_index: number;
content_index: number;
part: ResponseContentPart;
}
| {
type: "response.output_text.delta";
item_id: string;
output_index: number;
content_index: number;
delta: string;
}
| {
type: "response.output_text.done";
item_id: string;
output_index: number;
content_index: number;
text: string;
}
| {
type: "response.function_call_arguments.delta";
item_id: string;
output_index: number;
content_index: number;
delta: string;
}
| {
type: "response.function_call_arguments.done";
item_id: string;
output_index: number;
content_index: number;
arguments: string;
}
| {
type: "response.content_part.done";
item_id: string;
output_index: number;
content_index: number;
part: ResponseContentPart;
}
| {
type: "response.output_item.done";
output_index: number;
item: ResponseItemType;
}
| { type: "response.completed"; response: ResponseOutput }
| { type: "error"; code: string; message: string; param: string | null };
// Define a type for tool call data
type ToolCallData = {
id: string;
name: string;
arguments: string;
};
// Define a type for usage data
type UsageData = {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
input_tokens?: number;
input_tokens_details?: { cached_tokens: number };
output_tokens?: number;
output_tokens_details?: { reasoning_tokens: number };
[key: string]: unknown;
};
// Define a type for content output
type ResponseContentOutput =
| {
type: "function_call";
call_id: string;
name: string;
arguments: string;
[key: string]: unknown;
}
| {
type: "output_text";
text: string;
annotations: Array<unknown>;
[key: string]: unknown;
};
// Global map to store conversation histories
const conversationHistories = new Map<
string,
{
previous_response_id: string | null;
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>;
}
>();
// Utility function to generate unique IDs
function generateId(prefix: string = "msg"): string {
return `${prefix}_${Math.random().toString(36).substr(2, 9)}`;
}
// Function to convert ResponseInputItem to ChatCompletionMessageParam
type ResponseInputItem = ResponseCreateInput["input"][number];
function convertInputItemToMessage(
item: string | ResponseInputItem,
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
// Handle string inputs as content for a user message
if (typeof item === "string") {
return { role: "user", content: item };
}
// At this point we know it's a ResponseInputItem
const responseItem = item;
if (responseItem.type === "message") {
// Use a more specific type assertion for the message content
const content = Array.isArray(responseItem.content)
? responseItem.content
.filter((c) => typeof c === "object" && c.type === "input_text")
.map((c) =>
typeof c === "object" && "text" in c
? (c["text"] as string) || ""
: "",
)
.join("")
: "";
return { role: responseItem.role, content };
} else if (responseItem.type === "function_call_output") {
return {
role: "tool",
tool_call_id: responseItem.call_id,
content: responseItem.output,
};
}
throw new Error(`Unsupported input item type: ${responseItem.type}`);
}
// Function to get full messages including history
function getFullMessages(
input: ResponseCreateInput,
): Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
let baseHistory: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> =
[];
if (input.previous_response_id) {
const prev = conversationHistories.get(input.previous_response_id);
if (!prev) {
throw new Error(
`Previous response not found: ${input.previous_response_id}`,
);
}
baseHistory = prev.messages;
}
// Handle both string and ResponseInputItem in input.input
const newInputMessages = Array.isArray(input.input)
? input.input.map(convertInputItemToMessage)
: [convertInputItemToMessage(input.input)];
const messages = [...baseHistory, ...newInputMessages];
if (
input.instructions &&
messages[0]?.role !== "system" &&
messages[0]?.role !== "developer"
) {
return [{ role: "system", content: input.instructions }, ...messages];
}
return messages;
}
// Function to convert tools
function convertTools(
tools?: ResponseCreateInput["tools"],
): Array<OpenAI.Chat.Completions.ChatCompletionTool> | undefined {
return tools
?.filter((tool) => tool.type === "function")
.map((tool) => ({
type: "function" as const,
function: {
name: tool.name,
description: tool.description || undefined,
parameters: tool.parameters,
},
}));
}
// Main function with overloading
async function responsesCreateViaChatCompletions(
openai: OpenAI,
input: ResponseCreateInput & { stream: true },
): Promise<AsyncGenerator<ResponseEvent>>;
async function responsesCreateViaChatCompletions(
openai: OpenAI,
input: ResponseCreateInput & { stream?: false },
): Promise<ResponseOutput>;
async function responsesCreateViaChatCompletions(
openai: OpenAI,
input: ResponseCreateInput,
): Promise<ResponseOutput | AsyncGenerator<ResponseEvent>> {
if (input.stream) {
return streamResponses(openai, input);
} else {
return nonStreamResponses(openai, input);
}
}
// Non-streaming implementation
async function nonStreamResponses(
openai: OpenAI,
input: ResponseCreateInput,
): Promise<ResponseOutput> {
const fullMessages = getFullMessages(input);
const chatTools = convertTools(input.tools);
const webSearchOptions = input.tools?.some(
(tool) => tool.type === "function" && tool.name === "web_search",
)
? {}
: undefined;
const chatInput: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
model: input.model,
messages: fullMessages,
tools: chatTools,
web_search_options: webSearchOptions,
temperature: input.temperature,
top_p: input.top_p,
tool_choice: (input.tool_choice === "auto"
? "auto"
: input.tool_choice) as OpenAI.Chat.Completions.ChatCompletionCreateParams["tool_choice"],
user: input.user,
metadata: input.metadata,
};
try {
const chatResponse = await openai.chat.completions.create(chatInput);
if (!("choices" in chatResponse) || chatResponse.choices.length === 0) {
throw new Error("No choices in chat completion response");
}
const assistantMessage = chatResponse.choices?.[0]?.message;
if (!assistantMessage) {
throw new Error("No assistant message in chat completion response");
}
// Construct ResponseOutput
const responseId = generateId("resp");
const outputItemId = generateId("msg");
const outputContent: Array<ResponseContentOutput> = [];
// Check if the response contains tool calls
const hasFunctionCalls =
assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0;
if (hasFunctionCalls && assistantMessage.tool_calls) {
for (const toolCall of assistantMessage.tool_calls) {
if (toolCall.type === "function") {
outputContent.push({
type: "function_call",
call_id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
});
}
}
}
if (assistantMessage.content) {
outputContent.push({
type: "output_text",
text: assistantMessage.content,
annotations: [],
});
}
// Create response with appropriate status and properties
const responseOutput = {
id: responseId,
object: "response",
created_at: Math.floor(Date.now() / 1000),
status: hasFunctionCalls ? "requires_action" : "completed",
error: null,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
model: chatResponse.model,
output: [
{
type: "message",
id: outputItemId,
status: "completed",
role: "assistant",
content: outputContent,
},
],
parallel_tool_calls: input.parallel_tool_calls ?? false,
previous_response_id: input.previous_response_id ?? null,
reasoning: null,
temperature: input.temperature ?? 1.0,
text: { format: { type: "text" } },
tool_choice: input.tool_choice ?? "auto",
tools: input.tools ?? [],
top_p: input.top_p ?? 1.0,
truncation: input.truncation ?? "disabled",
usage: chatResponse.usage
? {
input_tokens: chatResponse.usage.prompt_tokens,
input_tokens_details: { cached_tokens: 0 },
output_tokens: chatResponse.usage.completion_tokens,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: chatResponse.usage.total_tokens,
}
: undefined,
user: input.user ?? undefined,
metadata: input.metadata ?? {},
output_text: "",
} as ResponseOutput;
// Add required_action property for tool calls
if (hasFunctionCalls && assistantMessage.tool_calls) {
// Define type with required action
type ResponseWithAction = Partial<ResponseOutput> & {
required_action: unknown;
};
// Use the defined type for the assertion
(responseOutput as ResponseWithAction).required_action = {
type: "submit_tool_outputs",
submit_tool_outputs: {
tool_calls: assistantMessage.tool_calls.map((toolCall) => ({
id: toolCall.id,
type: toolCall.type,
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments,
},
})),
},
};
}
// Store history
const newHistory = [...fullMessages, assistantMessage];
conversationHistories.set(responseId, {
previous_response_id: input.previous_response_id ?? null,
messages: newHistory,
});
return responseOutput;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to process chat completion: ${errorMessage}`);
}
}
// Streaming implementation
async function* streamResponses(
openai: OpenAI,
input: ResponseCreateInput,
): AsyncGenerator<ResponseEvent> {
const fullMessages = getFullMessages(input);
const chatTools = convertTools(input.tools);
const webSearchOptions = input.tools?.some(
(tool) => tool.type === "function" && tool.name === "web_search",
)
? {}
: undefined;
const chatInput: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
model: input.model,
messages: fullMessages,
tools: chatTools,
web_search_options: webSearchOptions,
temperature: input.temperature ?? 1.0,
top_p: input.top_p ?? 1.0,
tool_choice: (input.tool_choice === "auto"
? "auto"
: input.tool_choice) as OpenAI.Chat.Completions.ChatCompletionCreateParams["tool_choice"],
stream: true,
user: input.user,
metadata: input.metadata,
};
try {
// console.error("chatInput", JSON.stringify(chatInput));
const stream = await openai.chat.completions.create(chatInput);
// Initialize state
const responseId = generateId("resp");
const outputItemId = generateId("msg");
let textContentAdded = false;
let textContent = "";
const toolCalls = new Map<number, ToolCallData>();
let usage: UsageData | null = null;
const finalOutputItem: Array<ResponseContentOutput> = [];
// Initial response
const initialResponse: Partial<ResponseOutput> = {
id: responseId,
object: "response" as const,
created_at: Math.floor(Date.now() / 1000),
status: "in_progress" as const,
model: input.model,
output: [],
error: null,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
parallel_tool_calls: true,
previous_response_id: input.previous_response_id ?? null,
reasoning: null,
temperature: input.temperature ?? 1.0,
text: { format: { type: "text" } },
tool_choice: input.tool_choice ?? "auto",
tools: input.tools ?? [],
top_p: input.top_p ?? 1.0,
truncation: input.truncation ?? "disabled",
usage: undefined,
user: input.user ?? undefined,
metadata: input.metadata ?? {},
output_text: "",
};
yield { type: "response.created", response: initialResponse };
yield { type: "response.in_progress", response: initialResponse };
let isToolCall = false;
for await (const chunk of stream as AsyncIterable<OpenAI.ChatCompletionChunk>) {
// console.error('\nCHUNK: ', JSON.stringify(chunk));
const choice = chunk.choices[0];
if (!choice) {
continue;
}
if (
!isToolCall &&
(("tool_calls" in choice.delta && choice.delta.tool_calls) ||
choice.finish_reason === "tool_calls")
) {
isToolCall = true;
}
if (chunk.usage) {
usage = {
prompt_tokens: chunk.usage.prompt_tokens,
completion_tokens: chunk.usage.completion_tokens,
total_tokens: chunk.usage.total_tokens,
input_tokens: chunk.usage.prompt_tokens,
input_tokens_details: { cached_tokens: 0 },
output_tokens: chunk.usage.completion_tokens,
output_tokens_details: { reasoning_tokens: 0 },
};
}
if (isToolCall) {
for (const tcDelta of choice.delta.tool_calls || []) {
const tcIndex = tcDelta.index;
const content_index = textContentAdded ? tcIndex + 1 : tcIndex;
if (!toolCalls.has(tcIndex)) {
// New tool call
const toolCallId = tcDelta.id || generateId("call");
const functionName = tcDelta.function?.name || "";
yield {
type: "response.output_item.added",
item: {
type: "function_call",
id: outputItemId,
status: "in_progress",
call_id: toolCallId,
name: functionName,
arguments: "",
},
output_index: 0,
};
toolCalls.set(tcIndex, {
id: toolCallId,
name: functionName,
arguments: "",
});
}
if (tcDelta.function?.arguments) {
const current = toolCalls.get(tcIndex);
if (current) {
current.arguments += tcDelta.function.arguments;
yield {
type: "response.function_call_arguments.delta",
item_id: outputItemId,
output_index: 0,
content_index,
delta: tcDelta.function.arguments,
};
}
}
}
if (choice.finish_reason === "tool_calls") {
for (const [tcIndex, tc] of toolCalls) {
const item = {
type: "function_call",
id: outputItemId,
status: "completed",
call_id: tc.id,
name: tc.name,
arguments: tc.arguments,
};
yield {
type: "response.function_call_arguments.done",
item_id: outputItemId,
output_index: tcIndex,
content_index: textContentAdded ? tcIndex + 1 : tcIndex,
arguments: tc.arguments,
};
yield {
type: "response.output_item.done",
output_index: tcIndex,
item,
};
finalOutputItem.push(item as unknown as ResponseContentOutput);
}
} else {
continue;
}
} else {
if (!textContentAdded) {
yield {
type: "response.content_part.added",
item_id: outputItemId,
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
};
textContentAdded = true;
}
if (choice.delta.content?.length) {
yield {
type: "response.output_text.delta",
item_id: outputItemId,
output_index: 0,
content_index: 0,
delta: choice.delta.content,
};
textContent += choice.delta.content;
}
if (choice.finish_reason) {
yield {
type: "response.output_text.done",
item_id: outputItemId,
output_index: 0,
content_index: 0,
text: textContent,
};
yield {
type: "response.content_part.done",
item_id: outputItemId,
output_index: 0,
content_index: 0,
part: { type: "output_text", text: textContent, annotations: [] },
};
const item = {
type: "message",
id: outputItemId,
status: "completed",
role: "assistant",
content: [
{ type: "output_text", text: textContent, annotations: [] },
],
};
yield {
type: "response.output_item.done",
output_index: 0,
item,
};
finalOutputItem.push(item as unknown as ResponseContentOutput);
} else {
continue;
}
}
// Construct final response
const finalResponse: ResponseOutput = {
id: responseId,
object: "response" as const,
created_at: initialResponse.created_at || Math.floor(Date.now() / 1000),
status: "completed" as const,
error: null,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
model: chunk.model || input.model,
output: finalOutputItem as unknown as ResponseOutput["output"],
parallel_tool_calls: true,
previous_response_id: input.previous_response_id ?? null,
reasoning: null,
temperature: input.temperature ?? 1.0,
text: { format: { type: "text" } },
tool_choice: input.tool_choice ?? "auto",
tools: input.tools ?? [],
top_p: input.top_p ?? 1.0,
truncation: input.truncation ?? "disabled",
usage: usage as ResponseOutput["usage"],
user: input.user ?? undefined,
metadata: input.metadata ?? {},
output_text: "",
} as ResponseOutput;
// Store history
const assistantMessage = {
role: "assistant" as const,
content: textContent || null,
};
// Add tool_calls property if needed
if (toolCalls.size > 0) {
const toolCallsArray = Array.from(toolCalls.values()).map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
}));
// Define a more specific type for the assistant message with tool calls
type AssistantMessageWithToolCalls =
OpenAI.Chat.Completions.ChatCompletionMessageParam & {
tool_calls: Array<{
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}>;
};
// Use type assertion with the defined type
(assistantMessage as AssistantMessageWithToolCalls).tool_calls =
toolCallsArray;
}
const newHistory = [...fullMessages, assistantMessage];
conversationHistories.set(responseId, {
previous_response_id: input.previous_response_id ?? null,
messages: newHistory,
});
yield { type: "response.completed", response: finalResponse };
}
} catch (error) {
// console.error('\nERROR: ', JSON.stringify(error));
yield {
type: "error",
code:
error instanceof Error && "code" in error
? (error as { code: string }).code
: "unknown",
message: error instanceof Error ? error.message : String(error),
param: null,
};
}
}
export {
responsesCreateViaChatCompletions,
ResponseCreateInput,
ResponseOutput,
ResponseEvent,
};