@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.
- package/CHANGELOG.md +13 -0
- package/dist/types/auth-storage.d.ts +8 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/model-manager.d.ts +2 -0
- package/dist/types/provider-models/special.d.ts +1 -0
- package/dist/types/providers/openai-opencodex-responses.d.ts +10 -0
- package/dist/types/types.d.ts +1 -1
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-storage.ts +279 -34
- package/src/index.ts +1 -0
- package/src/model-manager.ts +5 -2
- package/src/provider-models/descriptors.ts +7 -1
- package/src/provider-models/special.ts +8 -0
- package/src/providers/openai-codex/response-handler.ts +24 -2
- package/src/providers/openai-codex-responses.ts +5 -1
- package/src/providers/openai-completions.ts +86 -8
- package/src/providers/openai-opencodex-responses.ts +173 -0
- package/src/providers/openai-responses.ts +76 -5
- package/src/stream.ts +1 -1
- package/src/types.ts +1 -0
- package/src/utils/discovery/openai-compatible.ts +18 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/oauth/index.ts +5 -0
- package/src/utils/oauth/types.ts +1 -0
package/src/model-manager.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
};
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -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)",
|