## Summary This PR introduces support for Azure OpenAI as a provider within the Codex CLI. Users can now configure the tool to leverage their Azure OpenAI deployments by specifying `"azure"` as the provider in `config.json` and setting the corresponding `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_API_VERSION` environment variables. This functionality is added alongside the existing provider options (OpenAI, OpenRouter, etc.). Related to #92 **Note:** This PR is currently in **Draft** status because tests on the `main` branch are failing. It will be marked as ready for review once the `main` branch is stable and tests are passing. --- ## What’s Changed - **Configuration (`config.ts`, `providers.ts`, `README.md`):** - Added `"azure"` to the supported `providers` list in `providers.ts`, specifying its name, default base URL structure, and environment variable key (`AZURE_OPENAI_API_KEY`). - Defined the `AZURE_OPENAI_API_VERSION` environment variable in `config.ts` with a default value (`2025-03-01-preview`). - Updated `README.md` to: - Include "azure" in the list of providers. - Add a configuration section for Azure OpenAI, detailing the required environment variables (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_API_VERSION`) with examples. - **Client Instantiation (`terminal-chat.tsx`, `singlepass-cli-app.tsx`, `agent-loop.ts`, `compact-summary.ts`, `model-utils.ts`):** - Modified various components and utility functions where the OpenAI client is initialized. - Added conditional logic to check if the configured `provider` is `"azure"`. - If the provider is Azure, the `AzureOpenAI` client from the `openai` package is instantiated, using the configured `baseURL`, `apiKey` (from `AZURE_OPENAI_API_KEY`), and `apiVersion` (from `AZURE_OPENAI_API_VERSION`). - Otherwise, the standard `OpenAI` client is instantiated as before. - **Dependencies:** - Relies on the `openai` package's built-in support for `AzureOpenAI`. No *new* external dependencies were added specifically for this Azure implementation beyond the `openai` package itself. --- ## How to Test *This has been tested locally and confirmed working with Azure OpenAI.* 1. **Configure `config.json`:** Ensure your `~/.codex/config.json` (or project-specific config) includes Azure and sets it as the active provider: ```json { "providers": { // ... other providers "azure": { "name": "AzureOpenAI", "baseURL": "https://YOUR_RESOURCE_NAME.openai.azure.com", // Replace with your Azure endpoint "envKey": "AZURE_OPENAI_API_KEY" } }, "provider": "azure", // Set Azure as the active provider "model": "o4-mini" // Use your Azure deployment name here // ... other config settings } ``` 2. **Set up Environment Variables:** ```bash # Set the API Key for your Azure OpenAI resource export AZURE_OPENAI_API_KEY="your-azure-api-key-here" # Set the API Version (Optional - defaults to `2025-03-01-preview` if not set) # Ensure this version is supported by your Azure deployment and endpoint export AZURE_OPENAI_API_VERSION="2025-03-01-preview" ``` 3. **Get the Codex CLI by building from this PR branch:** Clone your fork, checkout this branch (`feat/azure-openai`), navigate to `codex-cli`, and build: ```bash # cd /path/to/your/fork/codex git checkout feat/azure-openai # Or your branch name cd codex-cli corepack enable pnpm install pnpm build ``` 4. **Invoke Codex:** Run the locally built CLI using `node` from the `codex-cli` directory: ```bash node ./dist/cli.js "Explain the purpose of this PR" ``` *(Alternatively, if you ran `pnpm link` after building, you can use `codex "Explain the purpose of this PR"` from anywhere)*. 5. **Verify:** Confirm that the command executes successfully and interacts with your configured Azure OpenAI deployment. --- ## Tests - [x] Tested locally against an Azure OpenAI deployment using API Key authentication. Basic commands and interactions confirmed working. --- ## Checklist - [x] Added Azure provider details to configuration files (`providers.ts`, `config.ts`). - [x] Implemented conditional `AzureOpenAI` client initialization based on provider setting. - [x] Ensured `apiVersion` is passed correctly to the Azure client. - [x] Updated `README.md` with Azure OpenAI setup instructions. - [x] Manually tested core functionality against a live Azure OpenAI endpoint. - [x] Add/update automated tests for the Azure code path (pending `main` stability). cc @theabhinavdas @nikodem-wrona @fouad-openai @tibo-openai (adjust as needed) --- I have read the CLA Document and I hereby sign the CLA
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||
|
||
import { approximateTokensUsed } from "./approximate-tokens-used.js";
|
||
import { getApiKey } from "./config.js";
|
||
import { type SupportedModelId, openAiModelInfo } from "./model-info.js";
|
||
import { createOpenAIClient } from "./openai-client.js";
|
||
|
||
const MODEL_LIST_TIMEOUT_MS = 2_000; // 2 seconds
|
||
export const RECOMMENDED_MODELS: Array<string> = ["o4-mini", "o3"];
|
||
|
||
/**
|
||
* Background model loader / cache.
|
||
*
|
||
* We start fetching the list of available models from OpenAI once the CLI
|
||
* enters interactive mode. The request is made exactly once during the
|
||
* lifetime of the process and the results are cached for subsequent calls.
|
||
*/
|
||
async function fetchModels(provider: string): Promise<Array<string>> {
|
||
// If the user has not configured an API key we cannot retrieve the models.
|
||
if (!getApiKey(provider)) {
|
||
throw new Error("No API key configured for provider: " + provider);
|
||
}
|
||
|
||
try {
|
||
const openai = createOpenAIClient({ provider });
|
||
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") {
|
||
let modelStr = model.id;
|
||
// Fix for gemini.
|
||
if (modelStr.startsWith("models/")) {
|
||
modelStr = modelStr.replace("models/", "");
|
||
}
|
||
models.push(modelStr);
|
||
}
|
||
}
|
||
|
||
return models.sort();
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Returns the list of models available for the provided key / credentials. */
|
||
export async function getAvailableModels(
|
||
provider: string,
|
||
): Promise<Array<string>> {
|
||
return fetchModels(provider.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* Verifies that the provided model identifier is present in the set returned by
|
||
* {@link getAvailableModels}.
|
||
*/
|
||
export async function isModelSupportedForResponses(
|
||
provider: string,
|
||
model: string | undefined | null,
|
||
): Promise<boolean> {
|
||
if (
|
||
typeof model !== "string" ||
|
||
model.trim() === "" ||
|
||
RECOMMENDED_MODELS.includes(model)
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
const models = await Promise.race<Array<string>>([
|
||
getAvailableModels(provider),
|
||
new Promise<Array<string>>((resolve) =>
|
||
setTimeout(() => resolve([]), MODEL_LIST_TIMEOUT_MS),
|
||
),
|
||
]);
|
||
|
||
// If the timeout fired we get an empty list → treat as supported to avoid
|
||
// false negatives.
|
||
if (models.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
return models.includes(model.trim());
|
||
} catch {
|
||
// Network or library failure → don't block start‑up.
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/** Returns the maximum context length (in tokens) for a given model. */
|
||
export function maxTokensForModel(model: string): number {
|
||
if (model in openAiModelInfo) {
|
||
return openAiModelInfo[model as SupportedModelId].maxContextLength;
|
||
}
|
||
|
||
// fallback to heuristics for models not in the registry
|
||
const lower = model.toLowerCase();
|
||
if (lower.includes("32k")) {
|
||
return 32000;
|
||
}
|
||
if (lower.includes("16k")) {
|
||
return 16000;
|
||
}
|
||
if (lower.includes("8k")) {
|
||
return 8000;
|
||
}
|
||
if (lower.includes("4k")) {
|
||
return 4000;
|
||
}
|
||
return 128000; // Default to 128k for any other model.
|
||
}
|
||
|
||
/** Calculates the percentage of tokens remaining in context for a model. */
|
||
export function calculateContextPercentRemaining(
|
||
items: Array<ResponseItem>,
|
||
model: string,
|
||
): number {
|
||
const used = approximateTokensUsed(items);
|
||
const max = maxTokensForModel(model);
|
||
const remaining = Math.max(0, max - used);
|
||
return (remaining / max) * 100;
|
||
}
|
||
|
||
/**
|
||
* Type‑guard that narrows a {@link ResponseItem} to one that represents a
|
||
* user‑authored message. The OpenAI SDK represents both input *and* output
|
||
* messages with a discriminated union where:
|
||
* • `type` is the string literal "message" and
|
||
* • `role` is one of "user" | "assistant" | "system" | "developer".
|
||
*
|
||
* For the purposes of de‑duplication we only care about *user* messages so we
|
||
* detect those here in a single, reusable helper.
|
||
*/
|
||
function isUserMessage(
|
||
item: ResponseItem,
|
||
): item is ResponseItem & { type: "message"; role: "user"; content: unknown } {
|
||
return item.type === "message" && (item as { role?: string }).role === "user";
|
||
}
|
||
|
||
/**
|
||
* Deduplicate the stream of {@link ResponseItem}s before they are persisted in
|
||
* component state.
|
||
*
|
||
* Historically we used the (optional) {@code id} field returned by the
|
||
* OpenAI streaming API as the primary key: the first occurrence of any given
|
||
* {@code id} “won” and subsequent duplicates were dropped. In practice this
|
||
* proved brittle because locally‑generated user messages don’t include an
|
||
* {@code id}. The result was that if a user quickly pressed <Enter> twice the
|
||
* exact same message would appear twice in the transcript.
|
||
*
|
||
* The new rules are therefore:
|
||
* 1. If a {@link ResponseItem} has an {@code id} keep only the *first*
|
||
* occurrence of that {@code id} (this retains the previous behaviour for
|
||
* assistant / tool messages).
|
||
* 2. Additionally, collapse *consecutive* user messages with identical
|
||
* content. Two messages are considered identical when their serialized
|
||
* {@code content} array matches exactly. We purposefully restrict this
|
||
* to **adjacent** duplicates so that legitimately repeated questions at
|
||
* a later point in the conversation are still shown.
|
||
*/
|
||
export function uniqueById(items: Array<ResponseItem>): Array<ResponseItem> {
|
||
const seenIds = new Set<string>();
|
||
const deduped: Array<ResponseItem> = [];
|
||
|
||
for (const item of items) {
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// Rule #1 – de‑duplicate by id when present
|
||
// ──────────────────────────────────────────────────────────────────
|
||
if (typeof item.id === "string" && item.id.length > 0) {
|
||
if (seenIds.has(item.id)) {
|
||
continue; // skip duplicates
|
||
}
|
||
seenIds.add(item.id);
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// Rule #2 – collapse consecutive identical user messages
|
||
// ──────────────────────────────────────────────────────────────────
|
||
if (isUserMessage(item) && deduped.length > 0) {
|
||
const prev = deduped[deduped.length - 1]!;
|
||
|
||
if (
|
||
isUserMessage(prev) &&
|
||
// Note: the `content` field is an array of message parts. Performing
|
||
// a deep compare is over‑kill here; serialising to JSON is sufficient
|
||
// (and fast for the tiny payloads involved).
|
||
JSON.stringify(prev.content) === JSON.stringify(item.content)
|
||
) {
|
||
continue; // skip duplicate user message
|
||
}
|
||
}
|
||
|
||
deduped.push(item);
|
||
}
|
||
|
||
return deduped;
|
||
}
|