diff --git a/readme-dev.md b/readme-dev.md index e7e2c0dc..f7aadb09 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -177,6 +177,21 @@ schema alone: - Other AIR `_meta` keys (goal, message phase, mode kind, permission presentation) go only to AIR clients, as on v1. Every v2 permission request still gets its prompt `title` and `description`. +#### Models of a `providers/set` gateway + +`providers/set` lists the models of the gateway (`GET {baseUrl}/models`, with the headers Codex sends to it) +before it responds. The models reported to the client are then only the Codex models whose `model` or `id` the +gateway lists, matched without case and date suffix. A session whose thread model the gateway does not list uses +the default of these models; a thread model that the gateway lists but Codex does not know is kept. A gateway that +lists none of the Codex models leaves no model option at all, and the session keeps the thread's model as current. +Sessions already open get the models of the new routing when the provider restart resumes them, after +`providers/disable` too. A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is +narrowed. A failed listing (network error, a status other than 2xx or 4xx, malformed body, or the timeout) fails +`providers/set` with `internal_error` and keeps the current routing; a base URL that is not http(s) fails with +`invalid_params`. A listing that finishes after a newer `providers/set` or `providers/disable` was applied is +dropped. The `gateway` auth method and providers from +the Codex config are not listed. + #### Known gaps - After `providers/disable("openai")`, `providers/list` still reports native OpenAI routing as the diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 287dd113..d70e2057 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -73,6 +73,14 @@ import { threadActiveWriterRequestError, } from "./CodexThreadErrors"; import {airCustomInstructions} from "./AirExtension"; +import { + GatewayListingError, + type GatewayModelIds, + isListedModelId, + listGatewayModels, + narrowToListedModels, +} from "./GatewayModelDiscovery"; +import {findSupportedEffort} from "./ModelConfigOption"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; /** @@ -444,41 +452,64 @@ export class CodexAcpClient { * method and the ACP `providers/set` method. Throws `invalid_params` for an * unsupported protocol or a malformed base URL. */ - private applyGatewayConfig(params: { - baseUrl: string; - headers?: Record | undefined; - providerName?: string | undefined; - apiType: acp.LlmProtocol; - }, source: GatewayConfigSource): void { - const apiType = params.apiType; - const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType]; - if (!wireApi) { + private applyGatewayConfig(params: GatewayRoutingParams, source: GatewayConfigSource): void { + const gatewayConfig = buildGatewayConfig(params); + this.gatewayConfigSource = source; + this.gatewayConfig = gatewayConfig; + } + + /** + * `providers/set`: the full configuration for the custom gateway provider, which {@link setProvider} + * applies. Rejects unknown provider ids and malformed base URLs with `invalid_params`. + * + * Lists the models of the gateway, which narrow the models reported to the client; + * see {@link fetchAvailableModels}. A failed listing fails the call with `internal_error`. + * The same configuration again reuses the previous listing. + */ + async prepareProvider(request: acp.SetProviderRequest): Promise { + if (request.providerId !== OPENAI_PROVIDER_ID) { throw RequestError.invalidParams( - {apiType}, - `Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`, + {providerId: request.providerId}, + `Unknown providerId "${request.providerId}"; only "${OPENAI_PROVIDER_ID}" is configurable`, ); } - if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) { - throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string"); + const gatewayConfig = buildGatewayConfig({ + apiType: request.apiType, + baseUrl: request.baseUrl, + headers: request.headers, + }); + if (!URL.canParse(request.baseUrl) || !["http:", "https:"].includes(new URL(request.baseUrl).protocol)) { + throw RequestError.invalidParams({baseUrl: request.baseUrl}, "baseUrl must be an http(s) URL"); } - const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0 - ? params.providerName - : "User-provided gateway"; - const headers: Record = { - "X-Client-Feature-ID": "codex", - ...params.headers, - }; + // Reuse needs the very same headers, so a rotated token lists again, and a failed listing then fails that + // `providers/set`. Deliberate: AIR keeps its proxy key for the IDE's lifetime and does not rotate it. + const previous = this.gatewayConfigSource === "acpProviders" ? this.gatewayConfig : null; + try { + gatewayConfig.listedModels = previous?.listedModels && sameGatewayRouting(previous, gatewayConfig) + ? previous.listedModels + : await listGatewayModels(gatewayConfig.config.base_url, gatewayConfig.config.http_headers); + } catch (error) { + if (error instanceof GatewayListingError) { + throw RequestError.internalError( + {providerId: request.providerId, apiType: request.apiType, baseUrl: request.baseUrl}, + `Model discovery for provider "${OPENAI_PROVIDER_ID}" failed: ${error.message}`, + ); + } + throw error; + } + return gatewayConfig; + } - this.gatewayConfigSource = source; - this.gatewayConfig = { - modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, - config: { - name: providerName, - base_url: params.baseUrl, - http_headers: headers, - wire_api: wireApi, - }, - }; + /** `providers/set`: replaces the routing with a configuration from {@link prepareProvider}. */ + setProvider(gatewayConfig: GatewayConfig): void { + this.gatewayConfigSource = "acpProviders"; + this.gatewayConfig = gatewayConfig; + logger.log("providers/set applied", { + providerId: OPENAI_PROVIDER_ID, + apiType: gatewayApiTypeFromConfig(gatewayConfig), + baseUrl: gatewayConfig.config.base_url, + headerNames: Object.keys(gatewayConfig.config.http_headers), + }); } /** @@ -525,30 +556,6 @@ export class CodexAcpClient { return {apiType: "openai", baseUrl: DEFAULT_OPENAI_BASE_URL}; } - /** - * `providers/set`: replaces the full configuration for the custom gateway - * provider. Rejects unknown provider ids with `invalid_params`. - */ - setProvider(request: acp.SetProviderRequest): void { - if (request.providerId !== OPENAI_PROVIDER_ID) { - throw RequestError.invalidParams( - {providerId: request.providerId}, - `Unknown providerId "${request.providerId}"; only "${OPENAI_PROVIDER_ID}" is configurable`, - ); - } - this.applyGatewayConfig({ - apiType: request.apiType, - baseUrl: request.baseUrl, - headers: request.headers, - }, "acpProviders"); - logger.log("providers/set applied", { - providerId: request.providerId, - apiType: request.apiType, - baseUrl: request.baseUrl, - headerNames: Object.keys(request.headers ?? {}), - }); - } - /** * `providers/disable`: disables the custom gateway provider. Disabling an * unknown provider id is idempotent success (RFD behavior ยง7). @@ -812,7 +819,7 @@ export class CodexAcpClient { }); const codexModels = await this.fetchAvailableModels(); - if (codexModels.length === 0) { + if (codexModels.length === 0 && !this.isNarrowedByGateway()) { throw new Error("Codex did not return any models"); } const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); @@ -1074,6 +1081,22 @@ export class CodexAcpClient { return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); } + // The gateway narrowed the models, so a model it does not list must not be used. A model it lists but Codex + // does not know is kept, as below. When it lists none, no model is available and the thread keeps its own; + // the gateway then refuses the requests. + const narrowedDefault = availableModels.find(m => m.isDefault) ?? availableModels[0]; + const listed = this.getGatewayListedModels(); + if (modelId && narrowedDefault && listed !== null && !isListedModelId(modelId, listed)) { + const defaultModel = narrowedDefault; + logger.log("The gateway does not list the model of the thread; using the default model", { + model: modelId, + defaultModel: defaultModel.id, + }); + const effort = findSupportedEffort(defaultModel.supportedReasoningEfforts, reasoningEffort ?? undefined) + ?? defaultModel.defaultReasoningEffort; + return ModelId.create(defaultModel.id, effort); + } + // The configured model is not in Codex's advertised catalog. This is // expected for custom providers (e.g. a self-hosted or third-party // model), whose model ids the catalog does not enumerate. Keep the @@ -1342,6 +1365,7 @@ export class CodexAcpClient { }); } + /** The Codex models, narrowed to the ones that the `providers/set` gateway lists. */ async fetchAvailableModels(): Promise { const models: Model[] = []; let cursor: string | null = null; @@ -1352,7 +1376,17 @@ export class CodexAcpClient { cursor = response.nextCursor; } while (cursor); - return models; + const listed = this.getGatewayListedModels(); + return listed === null ? models : narrowToListedModels(models, listed); + } + + private getGatewayListedModels(): GatewayModelIds | null { + return this.gatewayConfigSource === "acpProviders" ? this.gatewayConfig?.listedModels ?? null : null; + } + + /** Whether {@link fetchAvailableModels} narrows the models to a `providers/set` gateway's listing. */ + private isNarrowedByGateway(): boolean { + return this.getGatewayListedModels() !== null; } } @@ -1437,14 +1471,64 @@ type WireApi = "responses"; type GatewayConfigSource = "authentication" | "acpProviders"; -interface GatewayConfig { +export interface GatewayConfig { modelProvider: string; config: { name: string, base_url: string, http_headers: Record, wire_api: WireApi + }; + /** The models a `providers/set` gateway lists; `null` when it was not listed or the listing failed. */ + listedModels: GatewayModelIds | null; +} + +type GatewayRoutingParams = { + baseUrl: string; + headers?: Record | undefined; + providerName?: string | undefined; + apiType: acp.LlmProtocol; +}; + +/** The custom gateway routing for `params`, not yet listed. Throws `invalid_params` like {@link CodexAcpClient.setProvider}. */ +function buildGatewayConfig(params: GatewayRoutingParams): GatewayConfig { + const apiType = params.apiType; + const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType]; + if (!wireApi) { + throw RequestError.invalidParams( + {apiType}, + `Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`, + ); + } + if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) { + throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string"); } + const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0 + ? params.providerName + : "User-provided gateway"; + const headers: Record = { + "X-Client-Feature-ID": "codex", + ...params.headers, + }; + return { + modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, + config: { + name: providerName, + base_url: params.baseUrl, + http_headers: headers, + wire_api: wireApi, + }, + listedModels: null, + }; +} + +function sameGatewayRouting(left: GatewayConfig, right: GatewayConfig): boolean { + const leftHeaders = Object.entries(left.config.http_headers); + const rightHeaders = right.config.http_headers; + return left.config.base_url === right.config.base_url + && left.config.wire_api === right.config.wire_api + && leftHeaders.length === Object.keys(rightHeaders).length + && leftHeaders.every(([name, value]) => rightHeaders[name] === value); } function readMetaAdditionalRoots(meta?: Record | null): string[] | undefined { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 03bfa6ab..b86c21ee 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -527,6 +527,12 @@ export class CodexAcpServer { private readonly recovery: AppServerRecovery | null; private initializeRequest: Pick | null = null; private providerUpdate: Promise | null = null; + /** + * Numbers `providers/set` and `providers/disable` requests in arrival order. A listing finishing after a newer + * request was applied is dropped; a newer request that failed drops nothing. + */ + private providerRequests = 0; + private appliedProviderRequest = 0; /** The AIR `sessionIndex` extension, see `SessionIndexService.ts`. */ readonly sessionIndex = new SessionIndexService({ connection: () => this.connection, @@ -1920,8 +1926,17 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { - this.codexAcpClient.setProvider(params); - await this.enqueueProviderUpdate((client) => client.setProvider(params)); + const request = ++this.providerRequests; + // A provider restart can replace the client while the models are listed, so the routing goes to the current one. + const gatewayConfig = await this.codexAcpClient.prepareProvider(params); + if (this.appliedProviderRequest > request) { + logger.log("providers/set dropped: a newer provider request was applied while the models were listed"); + return { }; + } + this.appliedProviderRequest = request; + this.codexAcpClient.setProvider(gatewayConfig); + // The restarted app-server takes the listing too and does not list the models again. + await this.enqueueProviderUpdate((client) => client.setProvider(gatewayConfig)); return { }; } @@ -1930,6 +1945,7 @@ export class CodexAcpServer { if (params.providerId !== OPENAI_PROVIDER_ID) { return { }; } + this.appliedProviderRequest = ++this.providerRequests; await this.enqueueProviderUpdate((client) => client.disableProvider(params)); return { }; } @@ -2011,14 +2027,19 @@ export class CodexAcpServer { additionalDirectories: session.additionalDirectories, mcpServers: session.mcpServers ?? [], }; - const resume = (onSubscribed?: () => void) => onSubscribed === undefined - ? replacement.resumeSession(request) - : replacement.resumeSession(request, onSubscribed); + let resumed: SessionMetadata | null = null; + const resume = async (onSubscribed?: () => void) => { + resumed = onSubscribed === undefined + ? await replacement.resumeSession(request) + : await replacement.resumeSession(request, onSubscribed); + return resumed; + }; await (this.recovery?.resumeForReplacement(session, replacementGeneration, replacement, resume) ?? resume()); session.authProvider = replacement.getModelProvider(); session.asyncTasks.refresh(); settleTrackerReady(session); logger.log("Resumed session after provider restart", {sessionId: session.sessionId}); + if (resumed) await this.applyResumedModels(session, replacement, resumed); } catch (error) { settleTrackerReady(null); if (error instanceof ThreadRefusedError || error instanceof SessionReplacedError @@ -2146,6 +2167,8 @@ export class CodexAcpServer { additionalDirectories: session.additionalDirectories, mcpServers: session.mcpServers ?? [], }, onSubscribed); + // A session the provider restart failed to resume gets the models of the routing here. + await this.applyResumedModels(session, client, metadata); return {collaborationMode: metadata.collaborationMode}; }, applyCollaborationMode: (session, client) => @@ -2482,6 +2505,27 @@ export class CodexAcpServer { sessionState.currentModelId = ModelId.create(model, effort).toString(); } + /** + * Takes the models of a session that a provider restart resumed: the provider decides which models there are. + * The session keeps its model when it can, the way a resumed thread keeps its own; see `createModelId`. + */ + private async applyResumedModels(sessionState: SessionState, client: CodexAcpClient, resumed: SessionMetadata): Promise { + const configOptions = JSON.stringify(this.createSessionConfigOptions(sessionState)); + const {model, effort} = ModelId.fromString(sessionState.currentModelId); + sessionState.availableModels = resumed.models; + sessionState.currentModelId = client.createModelId(resumed.models, model, effort as ReasoningEffort).toString(); + const currentModel = this.findCurrentModel(resumed.models, sessionState.currentModelId); + sessionState.supportedReasoningEfforts = currentModel?.supportedReasoningEfforts ?? []; + sessionState.supportedInputModalities = currentModel?.inputModalities ?? ["text", "image"]; + sessionState.currentModelSupportsFast = modelSupportsFast(currentModel); + if (JSON.stringify(this.createSessionConfigOptions(sessionState)) !== configOptions) { + await new ACPSessionConnection(this.connection, sessionState.sessionId).update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); + } + } + private applyModelAndEffort(sessionState: SessionState, model: Model, effort: ReasoningEffort): void { sessionState.currentModelId = ModelId.fromComponents(model, effort).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; @@ -2814,8 +2858,11 @@ export class CodexAcpServer { const configOptions = [ sessionState.agentMode.toConfigOption(sessionState.clientCapabilities.airClient), createCollaborationModeConfigOption(sessionState.collaborationMode), - createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId), ]; + // A gateway that lists none of the Codex models leaves no model to offer, so there is no model option at all. + if (sessionState.availableModels.length > 0) { + configOptions.push(createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId)); + } if (sessionState.supportedReasoningEfforts.length > 0) { configOptions.push( createReasoningEffortConfigOption( diff --git a/src/GatewayModelDiscovery.ts b/src/GatewayModelDiscovery.ts new file mode 100644 index 00000000..c4503d66 --- /dev/null +++ b/src/GatewayModelDiscovery.ts @@ -0,0 +1,121 @@ +import type {Model} from "./app-server/v2"; +import {logger} from "./Logger"; + +const LIST_TIMEOUT_MS = 10_000; +const MAX_REPORTED_BODY_CHARS = 500; + +/** The ids of the models a gateway lists, normalized by {@link normalizeModelId}. */ +export type GatewayModelIds = ReadonlySet; + +/** + * A gateway model listing that failed: a network error, a status other than 2xx or 4xx, a malformed body, or the timeout. + * The message names the listing path and the reason, never a header value. + */ +export class GatewayListingError extends Error { + override name = "GatewayListingError"; +} + +/** + * Lists the models of an OpenAI-compatible gateway: `GET {baseUrl}/models` with the headers Codex sends to it. + * Returns `null` when the gateway answers 4xx: it has no listing, and nothing is narrowed. + * Throws a {@link GatewayListingError} when the listing fails. + */ +export async function listGatewayModels( + baseUrl: string, + headers: Record, +): Promise { + const url = `${baseUrl.replace(/\/+$/, "")}/models`; + const parsedUrl = URL.canParse(url) ? new URL(url) : null; + const path = parsedUrl?.pathname ?? url; + if (parsedUrl?.username || parsedUrl?.password) { + // Node's fetch refuses such a URL, with a message that holds the credentials. + logger.log("[WARN] The gateway model listing failed", {path, reason: "the base URL carries credentials"}); + throw new GatewayListingError(`Model listing GET ${path} failed: the base URL carries credentials`); + } + const failure = (reason: string, details: Record): GatewayListingError => { + logger.log("[WARN] The gateway model listing failed", {url, reason, ...details}); + return new GatewayListingError(`Model listing GET ${path} ${reason}`); + }; + const requestHeaders = {...headers, "Accept": "application/json"}; + for (const [name, value] of Object.entries(requestHeaders)) { + // Node's fetch names an invalid header by its value, so check each header first. + try { + new Headers([[name, value]]); + } catch { + throw failure(`failed: the header "${name}" is invalid`, {}); + } + } + let response: Response; + let body: string; + try { + response = await fetch(url, { + method: "GET", + headers: requestHeaders, + signal: AbortSignal.timeout(LIST_TIMEOUT_MS), + }); + body = await response.text(); + } catch (error) { + throw failure(`failed: ${error instanceof Error ? error.message : String(error)}`, {}); + } + if (response.status >= 400 && response.status < 500) { + logger.log("The gateway has no model listing; models are not narrowed", {path, status: response.status}); + return null; + } + if (!response.ok) { + throw failure(`returned ${response.status}: ${body.slice(0, MAX_REPORTED_BODY_CHARS)}`, {status: response.status}); + } + let ids: string[] | null; + try { + ids = readModelIds(JSON.parse(body)); + } catch { + ids = null; + } + if (ids === null) { + throw failure("returned a malformed body", {}); + } + logger.log("The gateway listed its models", {url, models: ids}); + return new Set(ids.map(normalizeModelId)); +} + +function readModelIds(body: unknown): string[] | null { + if (typeof body !== "object" || body === null) return null; + const data = (body as {data?: unknown}).data; + if (!Array.isArray(data)) return null; + const ids = data + .map(entry => typeof entry === "object" && entry !== null ? (entry as {id?: unknown}).id : undefined) + .filter((id): id is string => typeof id === "string" && id.length > 0); + return ids.length === data.length ? ids : null; +} + +/** Lower case, without a date suffix: `GPT-5-2025-08-07` and `gpt-5-20250807` are both `gpt-5`. */ +function normalizeModelId(id: string): string { + return id.toLowerCase().replace(/-(\d{8}|\d{4}-\d{2}-\d{2})$/, ""); +} + +/** Whether the gateway lists the Codex model, by its `model` or its `id`. */ +export function isListedModel(model: Model, listed: GatewayModelIds): boolean { + return isListedModelId(model.model, listed) || isListedModelId(model.id, listed); +} + +/** Whether the gateway lists a model of this id. */ +export function isListedModelId(modelId: string, listed: GatewayModelIds): boolean { + return listed.has(normalizeModelId(modelId)); +} + +/** + * Keeps the Codex models the gateway lists, in Codex order, with exactly one default. + * A gateway that lists none of them leaves no model: the session then offers none. + */ +export function narrowToListedModels(models: Model[], listed: GatewayModelIds): Model[] { + const narrowed = models.filter(model => isListedModel(model, listed)); + if (narrowed.length === 0) { + logger.log("[WARN] The gateway lists none of the Codex models; no model is available", { + codexModels: models.map(model => model.id), + }); + return []; + } + const defaultIndex = Math.max(0, narrowed.findIndex(model => model.isDefault)); + return narrowed.map((model, index) => model.isDefault === (index === defaultIndex) + ? model + : {...model, isDefault: index === defaultIndex}); +} diff --git a/src/__tests__/CodexACPAgent/auth-status.test.ts b/src/__tests__/CodexACPAgent/auth-status.test.ts index 1d883d76..a8a7970c 100644 --- a/src/__tests__/CodexACPAgent/auth-status.test.ts +++ b/src/__tests__/CodexACPAgent/auth-status.test.ts @@ -4,6 +4,7 @@ import { createTestEventHandler, createTestModel, createTestSessionState, + stubGatewayModelListing, mockPromptTurn, type CodexMockTestFixture, type MethodCallEvent, @@ -94,6 +95,8 @@ function gatewayAuthRequest(providerName: string) { }; } +stubGatewayModelListing(["model-id"]); + describe("authStatus extension", () => { describe("Central provider display", () => { const centralStatus: AuthStatus = {kind: "gateway", label: "JetBrains Air Gateway"}; diff --git a/src/__tests__/CodexACPAgent/data/gateway-model-discovery-narrowed.json b/src/__tests__/CodexACPAgent/data/gateway-model-discovery-narrowed.json new file mode 100644 index 00000000..0a2a5bdc --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/gateway-model-discovery-narrowed.json @@ -0,0 +1,60 @@ +[ + { + "id": "gpt-5.4", + "model": "gpt-5.4", + "upgrade": null, + "upgradeInfo": null, + "availabilityNux": null, + "modelSpecialty": null, + "multiAgentVersion": null, + "availableAccessPrograms": null, + "displayName": "gpt-5.4", + "description": "gpt-5.4 model", + "hidden": false, + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Balanced" + } + ], + "defaultReasoningEffort": "medium", + "inputModalities": [ + "text", + "image" + ], + "supportsPersonality": false, + "additionalSpeedTiers": [], + "serviceTiers": [], + "defaultServiceTier": null, + "isDefault": true + }, + { + "id": "mini", + "model": "gpt-5.4-mini", + "upgrade": null, + "upgradeInfo": null, + "availabilityNux": null, + "modelSpecialty": null, + "multiAgentVersion": null, + "availableAccessPrograms": null, + "displayName": "mini", + "description": "mini model", + "hidden": false, + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Balanced" + } + ], + "defaultReasoningEffort": "medium", + "inputModalities": [ + "text", + "image" + ], + "supportsPersonality": false, + "additionalSpeedTiers": [], + "serviceTiers": [], + "defaultServiceTier": null, + "isDefault": false + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/gateway-model-discovery.test.ts b/src/__tests__/CodexACPAgent/gateway-model-discovery.test.ts new file mode 100644 index 00000000..6e5d9e6f --- /dev/null +++ b/src/__tests__/CodexACPAgent/gateway-model-discovery.test.ts @@ -0,0 +1,418 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; +import { + createCodexMockTestFixture, + createTestModel, + createTestSessionState, + deferred, + mockPromptTurn, + type CodexMockTestFixture, +} from "../acp-test-utils"; +import {OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import {MODEL_CONFIG_ID} from "../../ModelConfigOption"; +import type {Model} from "../../app-server/v2"; + +const GATEWAY = { + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://gateway.example/v1/", + headers: {Authorization: "Bearer secret"}, +} as const; + +const highEffort = {reasoningEffort: "high", description: "Thorough"} as const; + +function codexModels(): Model[] { + return [ + createTestModel({id: "gpt-5.5", isDefault: true, supportedReasoningEfforts: [highEffort], defaultReasoningEffort: "high"}), + createTestModel({id: "gpt-5.4", isDefault: false}), + createTestModel({id: "mini", model: "gpt-5.4-mini", isDefault: false}), + createTestModel({id: "gpt-5.3-codex", isDefault: false}), + ]; +} + +function listing(ids: string[]): Response { + return Response.json({object: "list", data: ids.map(id => ({id, object: "model"}))}); +} + +function createFixture( + models: Model[] = codexModels(), + restartCodexClient?: Parameters[0], +): CodexMockTestFixture { + const fixture = createCodexMockTestFixture(restartCodexClient); + const appServer = fixture.getCodexAppServerClient(); + vi.spyOn(appServer, "listModels").mockResolvedValue({data: models, nextCursor: null}); + vi.spyOn(fixture.getCodexAcpClient(), "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(fixture.getCodexAcpClient(), "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + return fixture; +} + +async function openSession(fixture: CodexMockTestFixture, threadModel: string) { + vi.spyOn(fixture.getCodexAppServerClient(), "threadStart").mockResolvedValue({ + thread: {id: "thread-id"}, + model: threadModel, + modelProvider: "custom-gateway", + reasoningEffort: "high", + serviceTier: null, + } as never); + return await fixture.getCodexAcpAgent().newSession({cwd: "/workspace", mcpServers: []}); +} + +function modelOptionValues(response: Awaited>): string[] { + const option = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); + return (option as {options: Array<{value: string}>}).options.map(o => o.value); +} + +describe("Gateway model discovery (providers/set)", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists the gateway models with the headers Codex sends", async () => { + fetchMock.mockResolvedValue(listing(["gpt-5.4"])); + const fixture = createFixture(); + + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("https://gateway.example/v1/models", { + method: "GET", + headers: { + "X-Client-Feature-ID": "codex", + "Authorization": "Bearer secret", + "Accept": "application/json", + }, + signal: expect.any(AbortSignal), + }); + }); + + it("narrows the models to the listed ones, ignoring case and date suffixes, with one default", async () => { + fetchMock.mockResolvedValue(listing(["GPT-5.4-2026-03-05", "gpt-5.4-mini-20260305", "unknown-model"])); + const fixture = createFixture(); + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + const models = await fixture.getCodexAcpClient().fetchAvailableModels(); + + expect(models.map(m => [m.id, m.isDefault])).toEqual([["gpt-5.4", true], ["mini", false]]); + await expect(JSON.stringify(models, null, 2)).toMatchFileSnapshot("data/gateway-model-discovery-narrowed.json"); + }); + + it("reports the narrowed models in a new session", async () => { + fetchMock.mockResolvedValue(listing(["gpt-5.4", "gpt-5.4-mini"])); + const fixture = createFixture(); + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + const response = await openSession(fixture, "gpt-5.4"); + + expect(modelOptionValues(response)).toEqual(["gpt-5.4", "mini"]); + expect(fixture.getCodexAcpAgent().getSessionState(response.sessionId).currentModelId).toBe("gpt-5.4[high]"); + }); + + it("replaces a thread model the gateway does not list with the narrowed default, also on turn/start", async () => { + fetchMock.mockResolvedValue(listing(["gpt-5.4", "gpt-5.4-mini"])); + const fixture = createFixture(); + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + const response = await openSession(fixture, "gpt-5.5"); + + const option = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); + expect(option?.currentValue).toBe("gpt-5.4"); + expect(modelOptionValues(response)).toEqual(["gpt-5.4", "mini"]); + // gpt-5.4 does not support the thread's `high` effort, so it takes its own default. + expect(fixture.getCodexAcpAgent().getSessionState(response.sessionId).currentModelId).toBe("gpt-5.4[medium]"); + + const turnStart = mockPromptTurn(fixture, response.sessionId); + await fixture.getCodexAcpAgent().prompt({sessionId: response.sessionId, prompt: [{type: "text", text: "hi"}]}); + expect(turnStart).toHaveBeenCalledWith(expect.objectContaining({model: "gpt-5.4", effort: "medium"})); + }); + + it("keeps a thread model that the gateway lists but Codex does not know", async () => { + fetchMock.mockResolvedValue(listing(["gpt-5.4", "gpt-6.1-sol"])); + const fixture = createFixture(); + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + const response = await openSession(fixture, "gpt-6.1-sol"); + + expect(response.configOptions?.find(o => o.id === MODEL_CONFIG_ID)?.currentValue).toBe("gpt-6.1-sol"); + expect(modelOptionValues(response)).toEqual(["gpt-6.1-sol", "gpt-5.4"]); + expect(fixture.getCodexAcpAgent().getSessionState(response.sessionId).currentModelId).toBe("gpt-6.1-sol[high]"); + }); + + it.each([ + ["a 5xx status", () => new Response("unavailable", {status: 503}), "Model listing GET /v1/models returned 503: unavailable"], + ["a malformed body", () => Response.json({models: ["gpt-5.4"]}), "Model listing GET /v1/models returned a malformed body"], + ["a network error", () => { throw new TypeError("fetch failed"); }, "Model listing GET /v1/models failed: fetch failed"], + ])("fails providers/set and keeps the current routing after %s", async (_name, answer, reason) => { + fetchMock.mockImplementation(async () => answer()); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + + await expect(agent.setProvider(GATEWAY)).rejects.toMatchObject({ + code: -32603, + message: expect.stringContaining(reason), + }); + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).not.toBe(GATEWAY.baseUrl); + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual(codexModels()); + }); + + it("fails providers/set without the value of an invalid header", async () => { + const fixture = createFixture(); + + const error = await fixture.getCodexAcpAgent().setProvider({...GATEWAY, headers: {Authorization: "Bearer secret\nx"}}) + .then(() => null, (e: acp.RequestError) => e); + + expect(error?.code).toBe(-32603); + expect(error?.message).toContain('Model listing GET /v1/models failed: the header "Authorization" is invalid'); + expect(error?.message).not.toContain("secret"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("fails providers/set without the credentials of a base URL that carries them", async () => { + const fixture = createFixture(); + + const error = await fixture.getCodexAcpAgent().setProvider({...GATEWAY, baseUrl: "https://user:pw@gateway.example/v1"}) + .then(() => null, (e: acp.RequestError) => e); + + expect(error?.code).toBe(-32603); + expect(error?.message).toContain("Model listing GET /v1/models failed: the base URL carries credentials"); + expect(error?.message).not.toContain("user:pw"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("drops a listing that a providers/disable overtook", async () => { + const listed = deferred(); + fetchMock.mockReturnValue(listed.promise); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + + const set = agent.setProvider(GATEWAY); + await agent.disableProvider({providerId: OPENAI_PROVIDER_ID}); + listed.resolve(listing(["gpt-5.4"])); + await set; + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).toBe("https://api.openai.com/v1"); + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual(codexModels()); + }); + + it("drops a listing that a newer providers/set overtook", async () => { + const firstListed = deferred(); + fetchMock.mockReturnValueOnce(firstListed.promise).mockResolvedValueOnce(listing(["gpt-5.3-codex"])); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + + const first = agent.setProvider(GATEWAY); + await agent.setProvider({...GATEWAY, baseUrl: "https://other.example/v1"}); + firstListed.resolve(listing(["gpt-5.4"])); + await first; + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).toBe("https://other.example/v1"); + expect((await fixture.getCodexAcpClient().fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.3-codex"]); + }); + + it("applies a listing that a newer providers/set failing overtook", async () => { + const firstListed = deferred(); + fetchMock.mockReturnValueOnce(firstListed.promise).mockResolvedValueOnce(new Response("unavailable", {status: 503})); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + + const first = agent.setProvider(GATEWAY); + await expect(agent.setProvider({...GATEWAY, baseUrl: "https://other.example/v1"})).rejects.toThrow(); + await expect(agent.setProvider({...GATEWAY, baseUrl: "localhost:8080/v1"})).rejects.toMatchObject({code: -32602}); + firstListed.resolve(listing(["gpt-5.4"])); + await first; + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).toBe(GATEWAY.baseUrl); + expect((await fixture.getCodexAcpClient().fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.4"]); + }); + + it.each(["localhost:8080/v1", "file:///tmp/models", "ws://gateway.example/v1"])( + "rejects the non-http base URL %s with invalid_params", + async (baseUrl) => { + const fixture = createFixture(); + + await expect(fixture.getCodexAcpAgent().setProvider({...GATEWAY, baseUrl})).rejects.toMatchObject({code: -32602}); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("keeps the previous gateway when setting another one fails to list", async () => { + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + fetchMock.mockResolvedValueOnce(listing(["gpt-5.4"])); + await agent.setProvider(GATEWAY); + fetchMock.mockResolvedValueOnce(new Response("unavailable", {status: 503})); + + await expect(agent.setProvider({...GATEWAY, baseUrl: "https://other.example/v1"})).rejects.toThrow(); + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).toBe(GATEWAY.baseUrl); + expect((await fixture.getCodexAcpClient().fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.4"]); + }); + + it("offers no model when the gateway lists none of the Codex models", async () => { + fetchMock.mockResolvedValue(listing([])); + const fixture = createFixture(); + await fixture.getCodexAcpAgent().setProvider(GATEWAY); + + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual([]); + const response = await openSession(fixture, "gpt-5.5"); + expect(response.configOptions?.find(o => o.id === MODEL_CONFIG_ID)).toBeUndefined(); + expect(fixture.getCodexAcpAgent().getSessionState(response.sessionId).currentModelId).toBe("gpt-5.5[high]"); + }); + + it.each([400, 401, 403, 404, 405, 429])("sets a gateway that answers %s and narrows nothing", async (status) => { + fetchMock.mockResolvedValue(new Response("no", {status})); + const fixture = createFixture(); + + await expect(fixture.getCodexAcpAgent().setProvider(GATEWAY)).resolves.toEqual({}); + + expect(fixture.getCodexAcpClient().listProviders()[0]?.current?.baseUrl).toBe(GATEWAY.baseUrl); + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual(codexModels()); + }); + + it("does not list models when providers/set was not used", async () => { + const fixture = createFixture(); + + const response = await openSession(fixture, "gpt-5.5"); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(modelOptionValues(response)).toEqual(["gpt-5.5", "gpt-5.4", "mini", "gpt-5.3-codex"]); + }); + + it("does not list models for the gateway auth method", async () => { + const fixture = createFixture(); + + await fixture.getCodexAcpAgent().authenticate({ + methodId: "gateway", + _meta: {gateway: {baseUrl: "https://gateway.example/v1", headers: {Authorization: "Bearer secret"}}}, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual(codexModels()); + }); + + it("stops narrowing after providers/disable", async () => { + fetchMock.mockResolvedValue(listing(["gpt-5.4"])); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + await agent.setProvider(GATEWAY); + expect((await fixture.getCodexAcpClient().fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.4"]); + + await agent.disableProvider({providerId: OPENAI_PROVIDER_ID}); + + expect(await fixture.getCodexAcpClient().fetchAvailableModels()).toEqual(codexModels()); + }); + + it("reuses the listing for the same configuration and lists again for another one", async () => { + fetchMock.mockImplementation(async () => listing(["gpt-5.4"])); + const fixture = createFixture(); + const agent = fixture.getCodexAcpAgent(); + + await agent.setProvider(GATEWAY); + await agent.setProvider({...GATEWAY, headers: {...GATEWAY.headers}}); + expect(fetchMock).toHaveBeenCalledOnce(); + + await agent.setProvider({...GATEWAY, headers: {Authorization: "Bearer other"}}); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("gives the listing to the app-server that a provider restart starts, without listing again", async () => { + fetchMock.mockImplementation(async () => listing(["gpt-5.4"])); + const replacement = createFixture().getCodexAcpClient(); + vi.spyOn(replacement, "initialize").mockResolvedValue(); + vi.spyOn(replacement, "resumeSession").mockResolvedValue({models: []} as never); + const fixture = createFixture(codexModels(), vi.fn().mockResolvedValue(replacement)); + const agent = fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const sessions = (agent as unknown as {sessions: Map>}).sessions; + sessions.set("thread-1", createTestSessionState({sessionId: "thread-1"})); + + await agent.setProvider(GATEWAY); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect((await replacement.fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.4"]); + }); + + it("applies a providers/set to the app-server that a provider restart starts while it lists the models", async () => { + const otherListed = deferred(); + fetchMock.mockImplementation(async (url: string) => url.startsWith("https://other.example") + ? otherListed.promise + : listing(["gpt-5.4"])); + const firstInitialized = deferred(); + const first = createFixture().getCodexAcpClient(); + vi.spyOn(first, "initialize").mockReturnValue(firstInitialized.promise); + vi.spyOn(first, "resumeSession").mockResolvedValue({models: []} as never); + const second = createFixture().getCodexAcpClient(); + vi.spyOn(second, "initialize").mockResolvedValue(); + vi.spyOn(second, "resumeSession").mockResolvedValue({models: []} as never); + const restart = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const fixture = createFixture(codexModels(), restart); + const agent = fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const sessions = (agent as unknown as {sessions: Map>}).sessions; + sessions.set("thread-1", createTestSessionState({sessionId: "thread-1"})); + + const firstSet = agent.setProvider(GATEWAY); + await vi.waitFor(() => expect(restart).toHaveBeenCalledOnce()); + const otherSet = agent.setProvider({...GATEWAY, baseUrl: "https://other.example/v1"}); + // The first restart installs its app-server while the other gateway lists its models. + firstInitialized.resolve(); + await firstSet; + otherListed.resolve(listing(["gpt-5.3-codex"])); + await otherSet; + + expect(restart).toHaveBeenCalledTimes(2); + expect(agent.listProviders({}).providers[0]?.current?.baseUrl).toBe("https://other.example/v1"); + expect(second.listProviders()[0]?.current?.baseUrl).toBe("https://other.example/v1"); + expect((await second.fetchAvailableModels()).map(m => m.id)).toEqual(["gpt-5.3-codex"]); + }); + + it("narrows the models of an open session after providers/set and widens them after providers/disable", async () => { + const replacements = [createFixture(), createFixture()].map(replacementFixture => { + vi.spyOn(replacementFixture.getCodexAppServerClient(), "threadResume").mockResolvedValue({ + thread: {id: "thread-id"}, + model: "gpt-5.5", + modelProvider: "custom-gateway", + reasoningEffort: "high", + serviceTier: null, + } as never); + const client = replacementFixture.getCodexAcpClient(); + vi.spyOn(client, "initialize").mockResolvedValue(); + return client; + }); + const restart = vi.fn().mockResolvedValueOnce(replacements[0]).mockResolvedValueOnce(replacements[1]); + fetchMock.mockResolvedValue(listing(["gpt-5.4", "gpt-5.4-mini"])); + const fixture = createFixture(codexModels(), restart); + const agent = fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const response = await openSession(fixture, "gpt-5.5"); + expect(modelOptionValues(response)).toEqual(["gpt-5.5", "gpt-5.4", "mini", "gpt-5.3-codex"]); + const lastModelUpdate = () => { + const updates = fixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate") + .map(event => (event.args[0] as acp.SessionNotification).update) + .filter(update => update.sessionUpdate === "config_option_update"); + const option = updates.at(-1)?.configOptions.find(o => o.id === MODEL_CONFIG_ID); + return { + currentValue: option?.currentValue, + values: (option as {options: Array<{value: string}>} | undefined)?.options.map(o => o.value), + }; + }; + + await agent.setProvider(GATEWAY); + + expect(agent.getSessionState(response.sessionId).currentModelId).toBe("gpt-5.4[medium]"); + expect(lastModelUpdate()).toEqual({currentValue: "gpt-5.4", values: ["gpt-5.4", "mini"]}); + + await agent.disableProvider({providerId: OPENAI_PROVIDER_ID}); + + expect(agent.getSessionState(response.sessionId).currentModelId).toBe("gpt-5.4[medium]"); + expect(lastModelUpdate()).toEqual({currentValue: "gpt-5.4", values: ["gpt-5.5", "gpt-5.4", "mini", "gpt-5.3-codex"]}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts b/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts index 0e343ab0..7212f99e 100644 --- a/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts +++ b/src/__tests__/CodexACPAgent/provider-restart-turn-tracking.test.ts @@ -22,6 +22,7 @@ import { type TranscriptEntry, } from './v2-prompt-harness'; import {expectConformingV2SessionUpdates} from './v2-session-update-guard'; +import {stubGatewayModelListing} from '../acp-test-utils'; function toolCallUpdates(transcript: TranscriptEntry[]) { return transcript.flatMap(entry => "sessionUpdate" in entry ? [entry.sessionUpdate] : []) @@ -60,6 +61,8 @@ async function runPromptToCompletion(client: PromptSession) { * running on the old client with a v2-only `idle`/`cancelled` (fix 2), since the old app-server process's EOF drops the notification and * no `turn/completed` for it ever arrives. */ +stubGatewayModelListing(["gpt-5"]); + describe('provider restart reinstalls the baseline turn tracker', () => { let closeClient: (() => void) | null = null; diff --git a/src/__tests__/CodexACPAgent/providers-v2.test.ts b/src/__tests__/CodexACPAgent/providers-v2.test.ts index cff2f9f3..2fdee25c 100644 --- a/src/__tests__/CodexACPAgent/providers-v2.test.ts +++ b/src/__tests__/CodexACPAgent/providers-v2.test.ts @@ -6,6 +6,7 @@ import {CodexAcpServer} from '../../CodexAcpServer'; import {CodexAcpClient, OPENAI_PROVIDER_ID} from '../../CodexAcpClient'; import {CodexAppServerClient} from '../../CodexAppServerClient'; import {createMockConnections} from './test-utils'; +import {stubGatewayModelListing} from '../acp-test-utils'; type ProtocolVersion = "v1" | "v2"; @@ -65,6 +66,8 @@ function toError(err: {code: number, message: string, data?: unknown}) { const VERSIONS: ProtocolVersion[] = ["v1", "v2"]; +stubGatewayModelListing(["model-id"]); + describe('providers/* over ACP v1 and v2', () => { let closeClient: (() => void) | null = null; @@ -172,8 +175,7 @@ describe('providers/* over ACP v1 and v2', () => { }); // The v2 SDK validates `baseUrl` as `format: uri` before codex-acp's own handler runs. - // v1's schema has no such format constraint, so v1's handler-level check (non-empty string - // only) is all that applies there. + // v1's schema has no such format constraint, so v1's handler-level check is all that applies there. it('v2 only: rejects a non-URL baseUrl with invalid_params from the SDK', async () => { const client = connectClient("v2"); closeClient = () => client.connection.close(); @@ -188,22 +190,21 @@ describe('providers/* over ACP v1 and v2', () => { expect(error?.code).toBe(-32602); }); - it('v1 pin: accepts a non-URL baseUrl (no format validation on v1)', async () => { + it('v1 pin: rejects a non-URL baseUrl with invalid_params before listing the models', async () => { const client = connectClient("v1"); closeClient = () => client.connection.close(); await client.initialize(); - const setResponse = await client.providers.set({ + const error = await client.providers.set({ providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "not-a-url", - }); + }).then(() => undefined, (e: {code?: number, message?: string}) => e); const listResponse = await client.providers.list({}); - expect(setResponse).toEqual({}); - expect(listResponse.providers[0]!.current).toEqual({ - apiType: "openai", - baseUrl: "not-a-url", - }); + expect(error?.code).toBe(-32602); + expect(error?.message).toContain("baseUrl must be an http(s) URL"); + expect(fetch).not.toHaveBeenCalled(); + expect(listResponse.providers[0]!.current?.baseUrl).not.toBe("not-a-url"); }); }); diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts index c2f79303..50d45d52 100644 --- a/src/__tests__/CodexACPAgent/providers.test.ts +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from "vitest"; import * as acp from "@agentclientprotocol/sdk"; -import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {createCodexMockTestFixture, createTestSessionState, stubGatewayModelListing} from "../acp-test-utils"; import {CodexAcpClient, CUSTOM_GATEWAY_PROVIDER_ID, OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; async function expectInvalidParams(fn: () => unknown): Promise { @@ -9,6 +9,8 @@ async function expectInvalidParams(fn: () => unknown): Promise { expect((caught as acp.RequestError).code).toBe(-32602); } +stubGatewayModelListing(["model-id"]); + describe("Configurable LLM providers (providers/*)", () => { it("advertises the providers capability in initialize", async () => { const fixture = createCodexMockTestFixture(); @@ -36,7 +38,7 @@ describe("Configurable LLM providers (providers/*)", () => { }); }); - it("restores the startup-configured proxy after the last override is disabled", () => { + it("restores the startup-configured proxy after the last override is disabled", async () => { const fixture = createCodexMockTestFixture(); const client = new CodexAcpClient(fixture.getCodexAppServerClient(), { model_provider: "company-proxy", @@ -44,11 +46,11 @@ describe("Configurable LLM providers (providers/*)", () => { "company-proxy": {base_url: "https://proxy.example/openai/v1"}, }, }); - client.setProvider({ + client.setProvider(await client.prepareProvider({ providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://temporary.example/openai/v1", - }); + })); client.disableProvider({providerId: OPENAI_PROVIDER_ID}); @@ -58,10 +60,10 @@ describe("Configurable LLM providers (providers/*)", () => { }); }); - it("reflects set routing in list without echoing headers", () => { + it("reflects set routing in list without echoing headers", async () => { const fixture = createCodexMockTestFixture(); const agent = fixture.getCodexAcpAgent(); - agent.setProvider({ + await agent.setProvider({ providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://llm-gateway.corp.example.com/openai/v1", @@ -107,10 +109,10 @@ describe("Configurable LLM providers (providers/*)", () => { })); }); - it("restores native OpenAI routing after disable", () => { + it("restores native OpenAI routing after disable", async () => { const fixture = createCodexMockTestFixture(); const agent = fixture.getCodexAcpAgent(); - agent.setProvider({ + await agent.setProvider({ providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://example.com", @@ -142,7 +144,7 @@ describe("Configurable LLM providers (providers/*)", () => { const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart") .mockRejectedValue(new Error("stop after capturing config")); - agent.setProvider({ + await agent.setProvider({ providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://llm-gateway.corp.example.com/openai/v1", @@ -198,11 +200,11 @@ describe("Configurable LLM providers (providers/*)", () => { const secondGatewayReplacement = createCodexMockTestFixture().getCodexAcpClient(); const nativeReplacement = createCodexMockTestFixture().getCodexAcpClient(); vi.spyOn(firstGatewayReplacement, "initialize").mockResolvedValue(); - const firstGatewayResume = vi.spyOn(firstGatewayReplacement, "resumeSession").mockResolvedValue({} as never); + const firstGatewayResume = vi.spyOn(firstGatewayReplacement, "resumeSession").mockResolvedValue({models: []} as never); vi.spyOn(secondGatewayReplacement, "initialize").mockResolvedValue(); - const secondGatewayResume = vi.spyOn(secondGatewayReplacement, "resumeSession").mockResolvedValue({} as never); + const secondGatewayResume = vi.spyOn(secondGatewayReplacement, "resumeSession").mockResolvedValue({models: []} as never); vi.spyOn(nativeReplacement, "initialize").mockResolvedValue(); - const nativeResume = vi.spyOn(nativeReplacement, "resumeSession").mockResolvedValue({} as never); + const nativeResume = vi.spyOn(nativeReplacement, "resumeSession").mockResolvedValue({models: []} as never); const restart = vi.fn() .mockResolvedValueOnce(firstGatewayReplacement) .mockResolvedValueOnce(secondGatewayReplacement) @@ -269,9 +271,9 @@ describe("Configurable LLM providers (providers/*)", () => { vi.spyOn(failedReplacement, "initialize").mockResolvedValue(); const failedResume = vi.spyOn(failedReplacement, "resumeSession") .mockRejectedValueOnce(new Error("thread-1 failed")) - .mockResolvedValue({} as never); + .mockResolvedValue({models: []} as never); vi.spyOn(recoveredReplacement, "initialize").mockResolvedValue(); - const recoveredResume = vi.spyOn(recoveredReplacement, "resumeSession").mockResolvedValue({} as never); + const recoveredResume = vi.spyOn(recoveredReplacement, "resumeSession").mockResolvedValue({models: []} as never); const restart = vi.fn() .mockResolvedValueOnce(failedReplacement) .mockResolvedValueOnce(recoveredReplacement); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 81b96038..2a47aacc 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -13,7 +13,7 @@ import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; import {DEFAULT_COLLABORATION_MODE} from "../CollaborationModeConfig"; -import {expect, vi} from "vitest"; +import {afterEach, beforeEach, expect, vi} from "vitest"; import type {Model, ReasoningEffortOption} from "../app-server/v2"; import {CodexSubagentEventRouter} from "../subagents/CodexSubagentEventRouter"; import {CodexEventHandler} from "../CodexEventHandler"; @@ -610,6 +610,19 @@ export function createTestEventHandler( ); } +/** + * Answers the gateway model listing of every `providers/set` in the file with `modelIds`, so its tests never reach + * the network. A failed listing would fail `providers/set`, and an empty one would leave the sessions no model. + */ +export function stubGatewayModelListing(modelIds: string[]): void { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({object: "list", data: modelIds.map(id => ({id}))}))); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); +} + /** A promise that the test resolves or rejects from outside. */ export function deferred(): {promise: Promise, resolve: (value: T) => void, reject: (reason: unknown) => void} { let resolve: (value: T) => void = () => {}; diff --git a/src/__tests__/app-server-recovery/recovery.test.ts b/src/__tests__/app-server-recovery/recovery.test.ts index 675487a7..b74a025e 100644 --- a/src/__tests__/app-server-recovery/recovery.test.ts +++ b/src/__tests__/app-server-recovery/recovery.test.ts @@ -4,6 +4,7 @@ import {ConnectionError, ConnectionErrors, ResponseError} from "vscode-jsonrpc/n import {createRecoveryFixture, defaultAnswer, initialize, MODEL, type RecoveryFixture, requestsOf} from "./recovery-fixture"; import {AppServerRecovery} from "../../app-server-recovery/AppServerRecovery"; import type {CodexAcpClient} from "../../CodexAcpClient"; +import {stubGatewayModelListing} from "../acp-test-utils"; const resumed = (id: string) => ({ thread: {id, turns: [], historyMode: "paginated", status: {type: "idle"}, preview: "", ephemeral: false, modelProvider: "openai", @@ -46,6 +47,8 @@ function completeTurn(fixture: RecoveryFixture, sessionId: string, turnId = "tur }); } +stubGatewayModelListing(["gpt-test"]); + describe("app-server recovery", () => { it("starts the app-server again on the next request after an idle crash, with the same handshake", async () => { const fixture = createRecoveryFixture();