@gajae-code/ai 0.12.7 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,6 +56,8 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
56
56
  export interface ModelResolutionResult<TApi extends Api = Api> {
57
57
  models: Model<TApi>[];
58
58
  stale: boolean;
59
+ /** Whether this resolution successfully fetched dynamic models. */
60
+ fetched: boolean;
59
61
  }
60
62
 
61
63
  /**
@@ -147,13 +149,13 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
147
149
  ) {
148
150
  const cachedModels = passModelList<TApi>(cache.models);
149
151
  if (!hasStaticTransportDrift(staticModels, cachedModels)) {
150
- return { models: cachedModels, stale: false };
152
+ return { models: cachedModels, stale: false, fetched: false };
151
153
  }
152
154
  const repairedModels = mergeDynamicModels(staticModels, cachedModels);
153
155
  if (options.canPublishCache?.() ?? true) {
154
156
  writeModelCache(options.providerId, now(), repairedModels, true, staticFingerprint, dbPath);
155
157
  }
156
- return { models: repairedModels, stale: false };
158
+ return { models: repairedModels, stale: false, fetched: false };
157
159
  }
158
160
 
159
161
  const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork
@@ -200,6 +202,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
200
202
  return {
201
203
  models,
202
204
  stale: !dynamicAuthoritative,
205
+ fetched: shouldFetchFromNetwork && dynamicFetchSucceeded,
203
206
  };
204
207
  }
205
208
 
@@ -48,7 +48,12 @@ import {
48
48
  xiaomiModelManagerOptions,
49
49
  zenmuxModelManagerOptions,
50
50
  } from "./openai-compat";
51
- import { cursorModelManagerOptions, glmZcodeModelManagerOptions, zaiModelManagerOptions } from "./special";
51
+ import {
52
+ cursorModelManagerOptions,
53
+ glmZcodeModelManagerOptions,
54
+ openCodexModelManagerOptions,
55
+ zaiModelManagerOptions,
56
+ } from "./special";
52
57
 
53
58
  /** Catalog discovery configuration for providers that support endpoint-based model listing. */
54
59
  export interface CatalogDiscoveryConfig {
@@ -139,6 +144,7 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
139
144
  catalog("Alibaba Token Plan", ["ALIBABA_TOKEN_PLAN_API_KEY"], { oauthProvider: "alibaba-token-plan" }),
140
145
  ),
141
146
  descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)),
147
+ descriptor("opencodex", "gpt-5.4", () => openCodexModelManagerOptions(), { allowUnauthenticated: true }),
142
148
  descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)),
143
149
  catalogDescriptor(
144
150
  "huggingface",
@@ -1,6 +1,14 @@
1
1
  import { once } from "@gajae-code/utils";
2
2
  import type { ModelManagerOptions } from "../model-manager";
3
+ import { fetchOpenCodexModels, OPENCODEX_MODEL_CACHE_TTL_MS } from "../providers/openai-opencodex-responses";
3
4
  import { fetchCodexModels } from "../utils/discovery/codex";
5
+ export function openCodexModelManagerOptions(): ModelManagerOptions<"openai-responses"> {
6
+ return {
7
+ providerId: "opencodex",
8
+ cacheTtlMs: OPENCODEX_MODEL_CACHE_TTL_MS,
9
+ fetchDynamicModels: fetchOpenCodexModels,
10
+ };
11
+ }
4
12
 
5
13
  // ---------------------------------------------------------------------------
6
14
  // OpenAI code provider
@@ -19,6 +19,9 @@ export type CodexErrorInfo = {
19
19
  rateLimits?: CodexRateLimits;
20
20
  raw?: string;
21
21
  };
22
+ // Matches the gate's bare rejection body ("Request blocked." / "Request
23
+ // blocked (…)") but never messages that merely mention blocking mid-text.
24
+ const REQUEST_BLOCKED_MESSAGE_RE = /^\s*request blocked\b/i;
22
25
 
23
26
  export async function parseCodexError(response: Response): Promise<CodexErrorInfo> {
24
27
  const raw = await response.text();
@@ -28,7 +31,7 @@ export async function parseCodexError(response: Response): Promise<CodexErrorInf
28
31
  let code: string | undefined;
29
32
 
30
33
  try {
31
- const parsed = JSON.parse(raw) as { error?: Record<string, unknown> };
34
+ const parsed = JSON.parse(raw) as { error?: Record<string, unknown>; detail?: unknown };
32
35
  const err = parsed?.error ?? {};
33
36
 
34
37
  const headers = response.headers;
@@ -67,11 +70,30 @@ export async function parseCodexError(response: Response): Promise<CodexErrorInf
67
70
  }
68
71
 
69
72
  const errMessage = (err as { message?: string }).message;
70
- message = errMessage || friendlyMessage || message;
73
+ // The chatgpt.com/backend-api gate rejects with a bare-`detail` body
74
+ // (`{"detail": "Request blocked."}`) that carries no `error.*` envelope.
75
+ const detail =
76
+ typeof parsed?.detail === "string"
77
+ ? parsed.detail
78
+ : typeof (parsed?.detail as { message?: unknown } | undefined)?.message === "string"
79
+ ? (parsed.detail as { message: string }).message
80
+ : undefined;
81
+ message = errMessage || detail || friendlyMessage || message;
71
82
  } catch {
72
83
  // raw body not JSON
73
84
  }
74
85
 
86
+ // A bare "Request blocked" body (detail-shaped JSON or plain text) is the
87
+ // pre-model gate's form of the deterministic `invalid_prompt` content
88
+ // rejection. It never carries a structured code, so classify it explicitly
89
+ // here; otherwise `isInvalidPromptError`, the codex non-retryable event set,
90
+ // and the session-level circuit breaker all miss it and the failure surfaces
91
+ // as an unexplained, unrepairable "Request Blocked".
92
+ if (!code && REQUEST_BLOCKED_MESSAGE_RE.test(message)) {
93
+ code = "invalid_prompt";
94
+ friendlyMessage = `${message.trim().replace(/\.+$/, "")} (code=invalid_prompt)`;
95
+ }
96
+
75
97
  return {
76
98
  message,
77
99
  status: response.status,
@@ -2823,7 +2823,7 @@ export function convertOpenAICodexResponsesTools(
2823
2823
  model: Model<"openai-codex-responses">,
2824
2824
  ): CodexToolPayload[] {
2825
2825
  const allowFreeform = supportsFreeformApplyPatchCodex(model);
2826
- return tools.map((tool): CodexToolPayload => {
2826
+ const payloads = tools.map((tool): CodexToolPayload => {
2827
2827
  if (allowFreeform && tool.customFormat) {
2828
2828
  return {
2829
2829
  type: "custom",
@@ -2847,6 +2847,10 @@ export function convertOpenAICodexResponsesTools(
2847
2847
  ...(effectiveStrict && { strict: true }),
2848
2848
  };
2849
2849
  });
2850
+ // Tool definitions bypass the `input`/`instructions` sanitizers, so a
2851
+ // leaked Harmony marker in an MCP/skill tool description or schema string
2852
+ // makes the gate reject every request (bare `Request blocked`).
2853
+ return neutralizeResponsesInputControlTokens(payloads);
2850
2854
  }
2851
2855
 
2852
2856
  function getString(value: unknown): string | undefined {
@@ -119,6 +119,69 @@ export function resolveOpenAICompletionsBaseUrlForTest(
119
119
  ): string {
120
120
  return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
121
121
  }
122
+ function appendUrlPath(baseUrl: string | undefined, path: string): string | undefined {
123
+ if (!baseUrl) return undefined;
124
+ const normalizedPath = path.replace(/^\/+/g, "");
125
+ try {
126
+ const parsed = new URL(baseUrl);
127
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}/${normalizedPath}`;
128
+ return parsed.toString();
129
+ } catch {
130
+ return `${baseUrl.replace(/\/+$/g, "")}/${normalizedPath}`;
131
+ }
132
+ }
133
+
134
+ type OpenAICompletionsQuery = string;
135
+
136
+ function splitBaseUrlQuery(baseUrl: string | undefined): {
137
+ baseUrl: string | undefined;
138
+ query?: OpenAICompletionsQuery;
139
+ } {
140
+ if (!baseUrl) return { baseUrl };
141
+ try {
142
+ const parsed = new URL(baseUrl);
143
+ if (!parsed.search) return { baseUrl };
144
+ const queryStart = baseUrl.indexOf("?");
145
+ const fragmentStart = baseUrl.indexOf("#", queryStart);
146
+ const query = baseUrl.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
147
+ if (!query) return { baseUrl };
148
+ parsed.search = "";
149
+ return {
150
+ baseUrl: parsed.toString(),
151
+ query,
152
+ };
153
+ } catch {
154
+ return { baseUrl };
155
+ }
156
+ }
157
+
158
+ function hasQueryParameter(query: OpenAICompletionsQuery | undefined, name: string): boolean {
159
+ return query ? new URLSearchParams(query).has(name) : false;
160
+ }
161
+
162
+ function appendRawQuery(url: string, query: OpenAICompletionsQuery | undefined): string {
163
+ if (!query) return url;
164
+ const fragmentStart = url.indexOf("#");
165
+ const beforeFragment = fragmentStart === -1 ? url : url.slice(0, fragmentStart);
166
+ const fragment = fragmentStart === -1 ? "" : url.slice(fragmentStart);
167
+ return `${beforeFragment}${beforeFragment.includes("?") ? "&" : "?"}${query}${fragment}`;
168
+ }
169
+
170
+ function buildRequestUrl(
171
+ baseUrl: string | undefined,
172
+ path: string,
173
+ query?: OpenAICompletionsQuery,
174
+ ): string | undefined {
175
+ const url = appendUrlPath(baseUrl, path);
176
+ return url ? appendRawQuery(url, query) : undefined;
177
+ }
178
+
179
+ function appendQueryToRequest(input: string | URL | Request, query?: OpenAICompletionsQuery): string | URL | Request {
180
+ if (!query) return input;
181
+ const url = appendRawQuery(input instanceof Request ? input.url : String(input), query);
182
+ if (input instanceof Request) return new Request(url, input as unknown as RequestInit);
183
+ return url;
184
+ }
122
185
 
123
186
  /**
124
187
  * Normalize tool call ID for Mistral.
@@ -466,6 +529,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
466
529
  client,
467
530
  copilotPremiumRequests,
468
531
  baseUrl,
532
+ requestBaseUrl,
533
+ requestQuery,
469
534
  requestHeaders,
470
535
  getCapturedErrorResponse: captureErrorResponse,
471
536
  clearCapturedErrorResponse,
@@ -511,7 +576,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
511
576
  api: output.api,
512
577
  model: model.id,
513
578
  method: "POST",
514
- url: `${baseUrl}/chat/completions`,
579
+ url: buildRequestUrl(requestBaseUrl, "chat/completions", requestQuery),
515
580
  headers: requestHeaders,
516
581
  body: params,
517
582
  };
@@ -1029,6 +1094,8 @@ async function createClient(
1029
1094
  client: OpenAI;
1030
1095
  copilotPremiumRequests: number | undefined;
1031
1096
  baseUrl: string | undefined;
1097
+ requestBaseUrl: string | undefined;
1098
+ requestQuery: OpenAICompletionsQuery | undefined;
1032
1099
  requestHeaders: Record<string, string>;
1033
1100
  getCapturedErrorResponse: () => CapturedHttpErrorResponse | undefined;
1034
1101
  clearCapturedErrorResponse: () => void;
@@ -1103,19 +1170,29 @@ async function createClient(
1103
1170
  }
1104
1171
  // Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD.
1105
1172
  // The generic openai-completions path adds neither, producing silent 404s.
1106
- let azureDefaultQuery: Record<string, string> | undefined;
1173
+ let azureQuery: OpenAICompletionsQuery | undefined;
1107
1174
  if (baseUrl?.includes(".openai.azure.com")) {
1108
- const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21";
1109
1175
  if (!baseUrl.includes("/deployments/")) {
1110
- baseUrl = `${baseUrl}/deployments/${model.id}`;
1176
+ baseUrl = appendUrlPath(baseUrl, `deployments/${model.id}`) ?? baseUrl;
1111
1177
  }
1112
- azureDefaultQuery = { "api-version": apiVersion };
1113
1178
  }
1179
+ const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl);
1180
+ if (baseUrl?.includes(".openai.azure.com") && !hasQueryParameter(endpointQuery, "api-version")) {
1181
+ azureQuery = new URLSearchParams({
1182
+ "api-version": $env.AZURE_OPENAI_API_VERSION || "2024-10-21",
1183
+ }).toString();
1184
+ }
1185
+ const endpointRequestQuery = endpointQuery;
1186
+ const requestQuery =
1187
+ [endpointRequestQuery, azureQuery].filter((query): query is string => query !== undefined).join("&") || undefined;
1114
1188
  let capturedErrorResponse: CapturedHttpErrorResponse | undefined;
1115
1189
  const baseFetch = fetchOverride ?? fetch;
1116
1190
  const wrappedFetch = Object.assign(
1117
1191
  async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
1118
- const response = await baseFetch(input, init);
1192
+ const response = await baseFetch(
1193
+ appendQueryToRequest(appendQueryToRequest(input, endpointRequestQuery), azureQuery),
1194
+ init,
1195
+ );
1119
1196
  if (response.ok) {
1120
1197
  capturedErrorResponse = undefined;
1121
1198
  return response;
@@ -1171,16 +1248,17 @@ async function createClient(
1171
1248
  return {
1172
1249
  client: new OpenAI({
1173
1250
  apiKey,
1174
- baseURL: baseUrl,
1251
+ baseURL: clientBaseUrl,
1175
1252
  dangerouslyAllowBrowser: true,
1176
1253
  maxRetries: resolveRetryBudget(requestMaxRetries, 5),
1177
1254
  defaultHeaders: headers,
1178
- defaultQuery: azureDefaultQuery,
1179
1255
  fetch: debugFetch,
1180
1256
  ...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
1181
1257
  }),
1182
1258
  copilotPremiumRequests,
1183
1259
  baseUrl,
1260
+ requestBaseUrl: clientBaseUrl,
1261
+ requestQuery,
1184
1262
  requestHeaders: headers,
1185
1263
  getCapturedErrorResponse: () => capturedErrorResponse,
1186
1264
  clearCapturedErrorResponse: () => {
@@ -0,0 +1,173 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import type { Model } from "../types";
7
+
8
+ export const OPENCODEX_DEFAULT_PORT = 10100;
9
+ export const OPENCODEX_PROBE_TIMEOUT_MS = 750;
10
+ export const OPENCODEX_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
11
+
12
+ interface RuntimePortFile {
13
+ hostname?: unknown;
14
+ host?: unknown;
15
+ port?: unknown;
16
+ }
17
+
18
+ interface HealthPayload {
19
+ ok?: unknown;
20
+ pid?: unknown;
21
+ port?: unknown;
22
+ version?: unknown;
23
+ }
24
+
25
+ interface CatalogRow {
26
+ id?: unknown;
27
+ model?: unknown;
28
+ name?: unknown;
29
+ displayName?: unknown;
30
+ contextWindow?: unknown;
31
+ maxTokens?: unknown;
32
+ reasoning?: unknown;
33
+ input?: unknown;
34
+ }
35
+
36
+ export interface OpenCodexEndpoint {
37
+ baseUrl: string;
38
+ }
39
+
40
+ function timeoutSignal(signal?: AbortSignal): AbortSignal {
41
+ return signal
42
+ ? AbortSignal.any([signal, AbortSignal.timeout(OPENCODEX_PROBE_TIMEOUT_MS)])
43
+ : AbortSignal.timeout(OPENCODEX_PROBE_TIMEOUT_MS);
44
+ }
45
+
46
+ function normalizeEndpoint(hostname: string, port: number): string | undefined {
47
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined;
48
+ const host = normalizeLoopbackHost(hostname);
49
+ if (!host) return undefined;
50
+ return `http://${formatEndpointHost(host)}:${port}`;
51
+ }
52
+
53
+ function normalizeLoopbackHost(hostname: string): string | undefined {
54
+ const host = hostname.trim().toLowerCase();
55
+ if (net.isIP(host) === 4 && host.startsWith("127.")) return host;
56
+ if (host === "::1") return host;
57
+ return undefined;
58
+ }
59
+
60
+ function formatEndpointHost(host: string): string {
61
+ return host.includes(":") ? `[${host}]` : host;
62
+ }
63
+
64
+ function healthPort(endpoint: string): number {
65
+ return Number(new URL(endpoint).port);
66
+ }
67
+
68
+ async function readRuntimeEndpoint(): Promise<string | undefined> {
69
+ const home = process.env.OPENCODEX_HOME?.trim() || path.join(os.homedir(), ".opencodex");
70
+ try {
71
+ const raw = JSON.parse(await fs.readFile(path.join(home, "runtime-port.json"), "utf8")) as RuntimePortFile;
72
+ const hostname =
73
+ typeof raw.hostname === "string" ? raw.hostname : typeof raw.host === "string" ? raw.host : "127.0.0.1";
74
+ const port = typeof raw.port === "number" ? raw.port : typeof raw.port === "string" ? Number(raw.port) : NaN;
75
+ return normalizeEndpoint(hostname, port);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ function candidateEndpoints(runtimeEndpoint: string | undefined): string[] {
82
+ const candidates = runtimeEndpoint ? [runtimeEndpoint] : [];
83
+ const fallback = normalizeEndpoint("127.0.0.1", OPENCODEX_DEFAULT_PORT);
84
+ if (fallback && !candidates.includes(fallback)) candidates.push(fallback);
85
+ return candidates;
86
+ }
87
+
88
+ async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
89
+ const response = await fetch(url, {
90
+ headers: { Accept: "application/json" },
91
+ redirect: "error",
92
+ signal: timeoutSignal(signal),
93
+ });
94
+ if (!response.ok) return undefined;
95
+ return response.json();
96
+ }
97
+
98
+ function isOpenCodexHealth(payload: unknown, expectedPort: number): boolean {
99
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
100
+ const health = payload as HealthPayload;
101
+ return health.ok === true && health.version === "opencodex" && health.port === expectedPort;
102
+ }
103
+
104
+ export async function resolveOpenCodexEndpoint(signal?: AbortSignal): Promise<OpenCodexEndpoint | undefined> {
105
+ const runtimeEndpoint = await readRuntimeEndpoint();
106
+ for (const candidate of candidateEndpoints(runtimeEndpoint)) {
107
+ try {
108
+ const health = await fetchJson(`${candidate}/healthz`, signal);
109
+ if (isOpenCodexHealth(health, healthPort(candidate))) return { baseUrl: candidate };
110
+ } catch {
111
+ // An unavailable or foreign listener is a normal provider absence.
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ function asPositiveNumber(value: unknown, fallback: number): number {
118
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
119
+ }
120
+
121
+ function normalizeCatalogPayload(payload: unknown): CatalogRow[] {
122
+ if (Array.isArray(payload)) return payload as CatalogRow[];
123
+ if (payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models)) {
124
+ return (payload as { models: CatalogRow[] }).models;
125
+ }
126
+ return [];
127
+ }
128
+
129
+ function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"openai-responses"> | undefined {
130
+ const rawId = typeof row.id === "string" ? row.id.trim() : typeof row.model === "string" ? row.model.trim() : "";
131
+ if (!rawId || rawId.includes("\n")) return undefined;
132
+ const publicId = `opencodex/${rawId}`;
133
+ const input =
134
+ Array.isArray(row.input) && row.input.every(value => value === "text" || value === "image")
135
+ ? row.input
136
+ : ["text"];
137
+ return {
138
+ id: publicId,
139
+ wireModelId: rawId,
140
+ name: typeof row.displayName === "string" ? row.displayName : typeof row.name === "string" ? row.name : rawId,
141
+ api: "openai-responses",
142
+ provider: "opencodex",
143
+ baseUrl: `${endpoint.baseUrl}/v1`,
144
+ reasoning: row.reasoning !== false,
145
+ input,
146
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
147
+ contextWindow: asPositiveNumber(row.contextWindow, 128_000),
148
+ maxTokens: asPositiveNumber(row.maxTokens, 16_384),
149
+ };
150
+ }
151
+
152
+ export async function fetchOpenCodexModels(): Promise<readonly Model<"openai-responses">[] | null> {
153
+ const endpoint = await resolveOpenCodexEndpoint();
154
+ if (!endpoint) return null;
155
+ try {
156
+ const rows = normalizeCatalogPayload(await fetchJson(`${endpoint.baseUrl}/api/models`));
157
+ const models = rows
158
+ .map(row => normalizeModel(row, endpoint))
159
+ .filter((model): model is Model<"openai-responses"> => model !== undefined);
160
+ return models.length > 0 ? models : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export async function checkOpenCodexStatus(onProgress?: (message: string) => void): Promise<void> {
167
+ const endpoint = await resolveOpenCodexEndpoint();
168
+ if (endpoint) {
169
+ onProgress?.(`OpenCodex is available at ${endpoint.baseUrl}`);
170
+ return;
171
+ }
172
+ onProgress?.("OpenCodex is unavailable; no identity-checked local proxy was found.");
173
+ }
@@ -172,6 +172,62 @@ export function resolveOpenAIProviderBaseUrlForTest(
172
172
  return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
173
173
  }
174
174
 
175
+ function appendUrlPath(baseUrl: string | undefined, path: string): string | undefined {
176
+ if (!baseUrl) return undefined;
177
+ const normalizedPath = path.replace(/^\/+/g, "");
178
+ try {
179
+ const parsed = new URL(baseUrl);
180
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}/${normalizedPath}`;
181
+ return parsed.toString();
182
+ } catch {
183
+ return `${baseUrl.replace(/\/+$/g, "")}/${normalizedPath}`;
184
+ }
185
+ }
186
+
187
+ type OpenAIResponsesQuery = string;
188
+
189
+ function splitBaseUrlQuery(baseUrl: string | undefined): {
190
+ baseUrl: string | undefined;
191
+ query?: OpenAIResponsesQuery;
192
+ } {
193
+ if (!baseUrl) return { baseUrl };
194
+ try {
195
+ const parsed = new URL(baseUrl);
196
+ if (!parsed.search) return { baseUrl };
197
+ const queryStart = baseUrl.indexOf("?");
198
+ const fragmentStart = baseUrl.indexOf("#", queryStart);
199
+ const query = baseUrl.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
200
+ if (!query) return { baseUrl };
201
+ parsed.search = "";
202
+ return {
203
+ baseUrl: parsed.toString(),
204
+ query,
205
+ };
206
+ } catch {
207
+ return { baseUrl };
208
+ }
209
+ }
210
+
211
+ function appendRawQuery(url: string, query: OpenAIResponsesQuery | undefined): string {
212
+ if (!query) return url;
213
+ const fragmentStart = url.indexOf("#");
214
+ const beforeFragment = fragmentStart === -1 ? url : url.slice(0, fragmentStart);
215
+ const fragment = fragmentStart === -1 ? "" : url.slice(fragmentStart);
216
+ return `${beforeFragment}${beforeFragment.includes("?") ? "&" : "?"}${query}${fragment}`;
217
+ }
218
+
219
+ function buildRequestUrl(baseUrl: string | undefined, path: string, query?: OpenAIResponsesQuery): string | undefined {
220
+ const url = appendUrlPath(baseUrl, path);
221
+ return url ? appendRawQuery(url, query) : undefined;
222
+ }
223
+
224
+ function appendQueryToRequest(input: string | URL | Request, query?: OpenAIResponsesQuery): string | URL | Request {
225
+ if (!query) return input;
226
+ const url = appendRawQuery(input instanceof Request ? input.url : String(input), query);
227
+ if (input instanceof Request) return new Request(url, input as unknown as RequestInit);
228
+ return url;
229
+ }
230
+
175
231
  const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
176
232
  "response.created",
177
233
  "response.output_item.added",
@@ -272,7 +328,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
272
328
  // Keep request headers and prompt-cache routing on the same session-derived value.
273
329
  const cacheSessionId = getOpenAIResponsesCacheSessionId(options);
274
330
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
275
- const { client, copilotPremiumRequests, baseUrl } = createClient(
331
+ const { client, copilotPremiumRequests, baseUrl, requestBaseUrl, requestQuery } = createClient(
276
332
  model,
277
333
  context,
278
334
  apiKey,
@@ -296,7 +352,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
296
352
  api: output.api,
297
353
  model: model.id,
298
354
  method: "POST",
299
- url: `${baseUrl}/responses`,
355
+ url: buildRequestUrl(requestBaseUrl, "responses", requestQuery),
300
356
  body: params,
301
357
  };
302
358
  const openaiStream = await callWithCopilotModelRetry(
@@ -439,6 +495,8 @@ function createClient(
439
495
  client: OpenAI;
440
496
  copilotPremiumRequests: number | undefined;
441
497
  baseUrl: string | undefined;
498
+ requestBaseUrl: string | undefined;
499
+ requestQuery: OpenAIResponsesQuery | undefined;
442
500
  } {
443
501
  if (!apiKey) {
444
502
  apiKey = $credentialEnv("OPENAI_API_KEY");
@@ -489,8 +547,15 @@ function createClient(
489
547
  headers.session_id ??= sessionId;
490
548
  headers["x-client-request-id"] ??= sessionId;
491
549
  }
550
+ const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl);
492
551
  const baseFetch = fetchOverride ?? fetch;
493
- const boundedFetch = wrapOpenAIFetchForBoundedRateLimits(baseFetch, maxRetryDelayMs);
552
+ const queryFetch = Object.assign(
553
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
554
+ return baseFetch(appendQueryToRequest(input, endpointQuery), init);
555
+ },
556
+ baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
557
+ );
558
+ const boundedFetch = wrapOpenAIFetchForBoundedRateLimits(queryFetch, maxRetryDelayMs);
494
559
  const transformedFetch = wrapFetchForOpenAIRequestTransform(
495
560
  boundedFetch,
496
561
  model.requestTransform,
@@ -499,7 +564,7 @@ function createClient(
499
564
  return {
500
565
  client: new OpenAI({
501
566
  apiKey,
502
- baseURL: baseUrl,
567
+ baseURL: clientBaseUrl,
503
568
  dangerouslyAllowBrowser: true,
504
569
  maxRetries: resolveRetryBudget(requestMaxRetries, 5),
505
570
  defaultHeaders: headers,
@@ -509,6 +574,8 @@ function createClient(
509
574
  }),
510
575
  copilotPremiumRequests,
511
576
  baseUrl,
577
+ requestBaseUrl: clientBaseUrl,
578
+ requestQuery: endpointQuery,
512
579
  };
513
580
  }
514
581
 
@@ -764,7 +831,7 @@ function isForcedOpenAIResponsesToolChoice(choice: unknown): boolean {
764
831
  /** @internal Exported for tests. */
765
832
  export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[] {
766
833
  const allowFreeform = supportsFreeformApplyPatch(model);
767
- return tools.map(tool => {
834
+ const payloads = tools.map(tool => {
768
835
  if (allowFreeform && tool.customFormat) {
769
836
  return {
770
837
  type: "custom",
@@ -792,4 +859,8 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o
792
859
  ...(effectiveStrict && { strict: true }),
793
860
  } as OpenAITool;
794
861
  });
862
+ // Tool definitions bypass the `input`/`instructions` sanitizers, so a
863
+ // leaked Harmony marker in an MCP/skill tool description or schema string
864
+ // rejects every gpt-5.x request (`Request blocked`).
865
+ return neutralizeResponsesInputControlTokens(payloads);
795
866
  }
package/src/stream.ts CHANGED
@@ -326,7 +326,7 @@ export function stream<TApi extends Api>(
326
326
  return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
327
327
  }
328
328
 
329
- const apiKey = options?.apiKey || getEnvApiKey(model.provider);
329
+ const apiKey = options?.apiKey || (model.provider === "opencodex" ? "local" : getEnvApiKey(model.provider));
330
330
  if (!apiKey) {
331
331
  throw new Error(formatMissingApiKeyError(model.provider));
332
332
  }
package/src/types.ts CHANGED
@@ -124,6 +124,7 @@ export type KnownProvider =
124
124
  | "google-vertex"
125
125
  | "openai"
126
126
  | "openai-codex"
127
+ | "opencodex"
127
128
  | "kimi-code"
128
129
  | "minimax-code"
129
130
  | "minimax-code-cn"
@@ -125,7 +125,7 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
125
125
  const fetchImpl = options.fetch ?? globalThis.fetch;
126
126
  let response: Response;
127
127
  try {
128
- response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, {
128
+ response = await fetchImpl(buildModelsUrl(baseUrl), {
129
129
  method: "GET",
130
130
  headers: requestHeaders,
131
131
  signal: options.signal,
@@ -193,7 +193,23 @@ function normalizeBaseUrl(baseUrl: string): string {
193
193
  if (!trimmed) {
194
194
  return "";
195
195
  }
196
- return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
196
+ try {
197
+ const parsed = new URL(trimmed);
198
+ parsed.pathname = parsed.pathname.replace(/\/+$/g, "");
199
+ return parsed.toString();
200
+ } catch {
201
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
202
+ }
203
+ }
204
+
205
+ function buildModelsUrl(baseUrl: string): string {
206
+ try {
207
+ const parsed = new URL(baseUrl);
208
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/g, "")}${MODELS_PATH}`;
209
+ return parsed.toString();
210
+ } catch {
211
+ return `${baseUrl}${MODELS_PATH}`;
212
+ }
197
213
  }
198
214
 
199
215
  function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null {
@@ -267,6 +267,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid
267
267
  function sanitizeDump(dump: RawHttpRequestDump): RawHttpRequestDump {
268
268
  return {
269
269
  ...dump,
270
+ url: redactRequestUrl(dump.url),
270
271
  headers: redactHeaders(dump.headers),
271
272
  body: sanitizeDumpBody(dump.body),
272
273
  };
@@ -25,6 +25,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
25
25
  name: "ChatGPT Plus/Pro (Codex Subscription)",
26
26
  available: true,
27
27
  },
28
+ {
29
+ id: "opencodex",
30
+ name: "OpenCodex (local proxy status)",
31
+ available: true,
32
+ },
28
33
  {
29
34
  id: "openai-codex-device",
30
35
  name: "ChatGPT Plus/Pro (Codex, headless/device)",