@bitkyc08/opencodex 2.7.34 → 2.7.35
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/README.md +1 -0
- package/gui/dist/assets/index-BunUANVE.js +52 -0
- package/gui/dist/index.html +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic.ts +22 -37
- package/src/adapters/cursor/cursor-errors.ts +11 -0
- package/src/adapters/cursor/discovery.ts +25 -0
- package/src/adapters/cursor/live-transport.ts +5 -0
- package/src/adapters/cursor/protobuf-events.ts +14 -0
- package/src/adapters/cursor/protobuf-request.ts +11 -1
- package/src/adapters/cursor/request-builder.ts +20 -3
- package/src/adapters/cursor.ts +69 -22
- package/src/adapters/google-http.ts +23 -4
- package/src/adapters/google-tool-schema.ts +143 -68
- package/src/adapters/google-wire-compiler.ts +228 -0
- package/src/adapters/google.ts +27 -18
- package/src/adapters/kiro.ts +25 -1
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +565 -0
- package/src/claude/agents-inject.ts +4 -2
- package/src/codex/catalog.ts +31 -2
- package/src/codex/home.ts +1 -1
- package/src/codex/quota.ts +43 -13
- package/src/index.ts +1 -0
- package/src/lib/sse-decoder.ts +84 -0
- package/src/providers/antigravity-models.ts +9 -3
- package/src/server/chat-completions.ts +258 -0
- package/src/server/gui-static.ts +1 -0
- package/src/server/index.ts +24 -0
- package/src/server/management-api.ts +9 -0
- package/src/server/responses.ts +28 -2
- package/src/types.ts +6 -3
- package/gui/dist/assets/index-BkmJJgg6.js +0 -52
package/src/codex/quota.ts
CHANGED
|
@@ -11,15 +11,24 @@ export type WhamUsageResponse = {
|
|
|
11
11
|
email?: string | null;
|
|
12
12
|
plan_type?: string | null;
|
|
13
13
|
rate_limit?: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Live WHAM payloads send explicit nulls for absent windows (issue #315 repro).
|
|
15
|
+
primary_window?: WhamUsageWindow | null;
|
|
16
|
+
secondary_window?: WhamUsageWindow | null;
|
|
17
|
+
tertiary_window?: WhamUsageWindow | null;
|
|
17
18
|
};
|
|
18
19
|
rate_limit_reset_credits?: {
|
|
19
20
|
available_count: number;
|
|
20
21
|
} | null;
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
type WhamUsageWindow = {
|
|
25
|
+
used_percent?: number;
|
|
26
|
+
reset_at?: number;
|
|
27
|
+
limit_window_seconds?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
|
|
31
|
+
|
|
23
32
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
24
33
|
|
|
25
34
|
export const CODEX_UNKNOWN_USAGE_SCORE = 100;
|
|
@@ -49,6 +58,13 @@ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boole
|
|
|
49
58
|
.some(value => typeof value === "number" && Number.isFinite(value));
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
|
|
62
|
+
const seconds = window?.limit_window_seconds;
|
|
63
|
+
return typeof seconds === "number"
|
|
64
|
+
&& Number.isFinite(seconds)
|
|
65
|
+
&& seconds >= MONTHLY_WINDOW_MIN_SECONDS;
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
export function updateAccountQuota(
|
|
53
69
|
accountId: string,
|
|
54
70
|
weekly: unknown,
|
|
@@ -110,16 +126,30 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
110
126
|
|
|
111
127
|
const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
|
|
112
128
|
const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free";
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const primaryResetAt = normalizeResetAt(
|
|
120
|
-
const secondaryResetAt = normalizeResetAt(
|
|
121
|
-
const
|
|
122
|
-
const
|
|
129
|
+
const primaryWindow = data.rate_limit.primary_window;
|
|
130
|
+
const secondaryWindow = data.rate_limit.secondary_window;
|
|
131
|
+
const tertiaryWindow = data.rate_limit.tertiary_window;
|
|
132
|
+
const primaryPercent = normalizeUsagePercent(primaryWindow?.used_percent);
|
|
133
|
+
const secondaryPercent = normalizeUsagePercent(secondaryWindow?.used_percent);
|
|
134
|
+
const tertiaryPercent = normalizeUsagePercent(tertiaryWindow?.used_percent);
|
|
135
|
+
const primaryResetAt = normalizeResetAt(primaryWindow?.reset_at);
|
|
136
|
+
const secondaryResetAt = normalizeResetAt(secondaryWindow?.reset_at);
|
|
137
|
+
const tertiaryResetAt = normalizeResetAt(tertiaryWindow?.reset_at);
|
|
138
|
+
const primaryIsMonthly = isExplicitMonthlyWindow(primaryWindow);
|
|
139
|
+
|
|
140
|
+
// [Decision Log]
|
|
141
|
+
// - 목적과 의도: distinguish weekly and roughly monthly WHAM primary windows without plan-name guesses.
|
|
142
|
+
// - 기존 구현 및 제약 조건: primary meant weekly, and older responses omit limit_window_seconds.
|
|
143
|
+
// - 검토한 주요 대안: exact-duration matching, plan-specific mapping, and a duration lower bound.
|
|
144
|
+
// - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
|
|
145
|
+
// - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
|
|
146
|
+
// - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
|
|
147
|
+
const weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent;
|
|
148
|
+
const weeklyResetAt = primaryIsMonthly
|
|
149
|
+
? secondaryResetAt
|
|
150
|
+
: primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
|
|
151
|
+
const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
|
|
152
|
+
const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
|
|
123
153
|
if (thirtyDayOnly) {
|
|
124
154
|
if (monthlyPercent !== undefined) {
|
|
125
155
|
quota.monthlyPercent = monthlyPercent;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface ServerSentEvent {
|
|
2
|
+
event?: string;
|
|
3
|
+
data: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decode text/event-stream records across arbitrary fetch chunk boundaries.
|
|
8
|
+
*
|
|
9
|
+
* The final record is dispatched at EOF even when the upstream omits the trailing blank line or
|
|
10
|
+
* final newline. That matters for compatible APIs that place a terminal event in the last bytes of
|
|
11
|
+
* the body: dropping that record turns a successful response into an adapter_eof failure.
|
|
12
|
+
*/
|
|
13
|
+
export async function* decodeServerSentEvents(
|
|
14
|
+
source: ReadableStream<Uint8Array>,
|
|
15
|
+
options?: { signal?: AbortSignal },
|
|
16
|
+
): AsyncGenerator<ServerSentEvent> {
|
|
17
|
+
const reader = source.getReader();
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
let buffer = "";
|
|
20
|
+
let event: string | undefined;
|
|
21
|
+
let dataLines: string[] = [];
|
|
22
|
+
// Prompt cancellation channel: an abort cancels the underlying reader directly, which
|
|
23
|
+
// settles any in-flight read() so a consumer's iterator.return() cannot hang behind an
|
|
24
|
+
// idle upstream (a plain generator return waits for the pending await first).
|
|
25
|
+
const signal = options?.signal;
|
|
26
|
+
const onAbort = () => { reader.cancel(signal?.reason).catch(() => { /* already closed */ }); };
|
|
27
|
+
if (signal?.aborted) onAbort();
|
|
28
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
|
|
30
|
+
const dispatch = (): ServerSentEvent | undefined => {
|
|
31
|
+
if (dataLines.length === 0) {
|
|
32
|
+
event = undefined;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const record = { ...(event ? { event } : {}), data: dataLines.join("\n") };
|
|
36
|
+
event = undefined;
|
|
37
|
+
dataLines = [];
|
|
38
|
+
return record;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const acceptLine = (rawLine: string): ServerSentEvent | undefined => {
|
|
42
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
43
|
+
if (line === "") return dispatch();
|
|
44
|
+
if (line.startsWith(":")) return undefined;
|
|
45
|
+
|
|
46
|
+
const colon = line.indexOf(":");
|
|
47
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
48
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
49
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
50
|
+
|
|
51
|
+
if (field === "event") event = value;
|
|
52
|
+
else if (field === "data") dataLines.push(value);
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
while (true) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (value) buffer += decoder.decode(value, { stream: !done });
|
|
60
|
+
|
|
61
|
+
let newline: number;
|
|
62
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
63
|
+
const record = acceptLine(buffer.slice(0, newline));
|
|
64
|
+
buffer = buffer.slice(newline + 1);
|
|
65
|
+
if (record) yield record;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!done) continue;
|
|
69
|
+
buffer += decoder.decode();
|
|
70
|
+
if (buffer.length > 0) {
|
|
71
|
+
const record = acceptLine(buffer);
|
|
72
|
+
buffer = "";
|
|
73
|
+
if (record) yield record;
|
|
74
|
+
}
|
|
75
|
+
const finalRecord = dispatch();
|
|
76
|
+
if (finalRecord) yield finalRecord;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
signal?.removeEventListener("abort", onAbort);
|
|
81
|
+
try { await reader.cancel(); } catch { /* already closed/errored */ }
|
|
82
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -47,6 +47,13 @@ const ANTIGRAVITY_DEFAULT_EFFORT: Record<string, string> = {
|
|
|
47
47
|
"gemini-3.1-pro": "high",
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
const ANTIGRAVITY_THINKING_LEVELS = new Set(["minimal", "low", "medium", "high"]);
|
|
51
|
+
|
|
52
|
+
function resolveAntigravityThinkingLevel(effort: string): string | undefined {
|
|
53
|
+
if (effort === "xhigh" || effort === "max" || effort === "ultra") return "high";
|
|
54
|
+
return ANTIGRAVITY_THINKING_LEVELS.has(effort) ? effort : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
// ── Visible client aliases (kept for saved-config compat, not picker-visible) ──
|
|
51
58
|
const ANTIGRAVITY_VISIBLE_MODEL_ALIASES: Record<string, string> = {
|
|
52
59
|
"gemini-3.1-pro-high": "gemini-pro-agent",
|
|
@@ -152,10 +159,9 @@ export function resolveAntigravityEffortWireModel(
|
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
// Rule 4: Claude models — effort via thinkingConfig only (no suffix variants).
|
|
155
|
-
//
|
|
156
|
-
// CLIProxyAPI proves CCA accepts thinkingConfig on base IDs (validation confirmed).
|
|
162
|
+
// CCA validates this field as Google's ThinkingLevel enum, whose highest value is `high`.
|
|
157
163
|
if (/^claude-/.test(modelId) && effort) {
|
|
158
|
-
return { wireModelId: modelId, thinkingLevel: effort };
|
|
164
|
+
return { wireModelId: modelId, thinkingLevel: resolveAntigravityThinkingLevel(effort) };
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
// Rule 5: everything else.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Chat Completions inbound (/v1/chat/completions) for GitHub Copilot App
|
|
3
|
+
* and other OpenAI-compatible clients.
|
|
4
|
+
*
|
|
5
|
+
* Translate-and-replay: Chat Completions body -> /v1/responses via handleResponses,
|
|
6
|
+
* then bridge the Responses output back to Chat Completions SSE/JSON.
|
|
7
|
+
*/
|
|
8
|
+
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
9
|
+
import { ChatCompletionsRequestError, chatCompletionsToResponsesBody } from "../chat/inbound";
|
|
10
|
+
import {
|
|
11
|
+
chatCompletionsErrorResponse,
|
|
12
|
+
collectChatCompletion,
|
|
13
|
+
isChatCompletionsStreamError,
|
|
14
|
+
responsesJsonToChatCompletion,
|
|
15
|
+
responsesSseToChatCompletionsSse,
|
|
16
|
+
} from "../chat/outbound";
|
|
17
|
+
import { estimateTokens } from "../lib/token-estimate";
|
|
18
|
+
import { routeModel } from "../router";
|
|
19
|
+
import type { OcxConfig } from "../types";
|
|
20
|
+
import { readJsonRequestBody } from "./request-decompress";
|
|
21
|
+
import {
|
|
22
|
+
addFinalRequestLog,
|
|
23
|
+
httpStatusForTerminalStatus,
|
|
24
|
+
recordFirstOutput,
|
|
25
|
+
type RequestLogContext,
|
|
26
|
+
type RequestLogEntry,
|
|
27
|
+
} from "./request-log";
|
|
28
|
+
import { responseWithDeferredRequestLog } from "./relay";
|
|
29
|
+
import { handleResponses } from "./responses";
|
|
30
|
+
|
|
31
|
+
type Rec = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
function isRec(v: unknown): v is Rec {
|
|
34
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
}
|
|
36
|
+
async function readChatBody(req: Request): Promise<unknown> {
|
|
37
|
+
try {
|
|
38
|
+
return await readJsonRequestBody(req);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw new ChatCompletionsRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function handleChatCompletions(
|
|
45
|
+
req: Request,
|
|
46
|
+
config: OcxConfig,
|
|
47
|
+
logCtx: RequestLogContext,
|
|
48
|
+
logIds?: { requestId: string; start: number },
|
|
49
|
+
): Promise<Response> {
|
|
50
|
+
let chatBody: unknown;
|
|
51
|
+
let internalBody: Rec;
|
|
52
|
+
try {
|
|
53
|
+
chatBody = await readChatBody(req);
|
|
54
|
+
internalBody = chatCompletionsToResponsesBody(chatBody);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const status = err instanceof ChatCompletionsRequestError ? 400 : 500;
|
|
57
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
58
|
+
return chatCompletionsErrorResponse(status, err instanceof Error ? err.message : String(err));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const requestedModel = (chatBody as Rec).model as string;
|
|
62
|
+
const stream = internalBody.stream === true;
|
|
63
|
+
// Routed adapters only support streamed turns; always stream internally and fold
|
|
64
|
+
// for non-streaming clients.
|
|
65
|
+
internalBody.stream = true;
|
|
66
|
+
|
|
67
|
+
let nativeRoute = false;
|
|
68
|
+
let directRoute = false;
|
|
69
|
+
try {
|
|
70
|
+
const route = routeModel(config, internalBody.model as string);
|
|
71
|
+
logCtx.model = route.modelId;
|
|
72
|
+
logCtx.providerAdapter = route.provider.adapter;
|
|
73
|
+
logCtx.requestedModel = requestedModel;
|
|
74
|
+
logCtx.provider = route.providerName;
|
|
75
|
+
if (route.provider.adapter === "openai-responses") {
|
|
76
|
+
nativeRoute = true;
|
|
77
|
+
directRoute = route.codexAccountMode === "direct";
|
|
78
|
+
// ChatGPT backend rejects store:true and unsupported sampling knobs.
|
|
79
|
+
internalBody.store = false;
|
|
80
|
+
delete internalBody.max_output_tokens;
|
|
81
|
+
delete internalBody.temperature;
|
|
82
|
+
delete internalBody.top_p;
|
|
83
|
+
delete internalBody.stop;
|
|
84
|
+
delete internalBody.user;
|
|
85
|
+
} else if (internalBody.store === undefined) {
|
|
86
|
+
internalBody.store = false;
|
|
87
|
+
}
|
|
88
|
+
if (route.provider.adapter === "openai-chat" && internalBody.text !== undefined) {
|
|
89
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
|
|
90
|
+
return chatCompletionsErrorResponse(400, "response_format is not supported for routed openai-chat models");
|
|
91
|
+
}
|
|
92
|
+
if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
|
|
93
|
+
const raw = chatBody as Rec;
|
|
94
|
+
const parts: string[] = [];
|
|
95
|
+
if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
|
|
96
|
+
if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
|
|
97
|
+
logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
|
|
98
|
+
}
|
|
99
|
+
if (internalBody.reasoning !== undefined) {
|
|
100
|
+
const { supportedLadderFor } = await import("./effort-policy");
|
|
101
|
+
const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
|
|
102
|
+
if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* unknown model: let handleResponses shape the 404 */
|
|
106
|
+
}
|
|
107
|
+
void nativeRoute;
|
|
108
|
+
|
|
109
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
110
|
+
for (const name of FORWARD_HEADERS) {
|
|
111
|
+
if (name === "authorization" && !directRoute) continue;
|
|
112
|
+
const value = req.headers.get(name);
|
|
113
|
+
if (value) headers.set(name, value);
|
|
114
|
+
}
|
|
115
|
+
// Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns.
|
|
116
|
+
if (!directRoute) try {
|
|
117
|
+
const { getMainAccountToken } = await import("../codex/main-account");
|
|
118
|
+
const token = getMainAccountToken();
|
|
119
|
+
if (token) {
|
|
120
|
+
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
121
|
+
headers.set("chatgpt-account-id", token.chatgptAccountId);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* optional */
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const internalReq = new Request("http://localhost/v1/responses", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers,
|
|
130
|
+
body: JSON.stringify(internalBody),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let nativeLogged = false;
|
|
134
|
+
const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" }) => {
|
|
135
|
+
if (!logIds || nativeLogged) return;
|
|
136
|
+
nativeLogged = true;
|
|
137
|
+
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
138
|
+
};
|
|
139
|
+
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
140
|
+
abortSignal: req.signal,
|
|
141
|
+
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
|
|
142
|
+
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
143
|
+
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
144
|
+
});
|
|
145
|
+
const response = logIds
|
|
146
|
+
? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx)
|
|
147
|
+
: upstream;
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
let message = `upstream error (${response.status})`;
|
|
151
|
+
try {
|
|
152
|
+
const text = await response.text();
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string };
|
|
155
|
+
const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined;
|
|
156
|
+
const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
|
|
157
|
+
message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message);
|
|
158
|
+
} catch {
|
|
159
|
+
if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
|
|
160
|
+
}
|
|
161
|
+
} catch { /* keep fallback */ }
|
|
162
|
+
const retryAfter = response.headers.get("retry-after");
|
|
163
|
+
return new Response(JSON.stringify({
|
|
164
|
+
error: {
|
|
165
|
+
message,
|
|
166
|
+
type: response.status === 401 ? "authentication_error"
|
|
167
|
+
: response.status === 429 ? "rate_limit_error"
|
|
168
|
+
: response.status >= 500 ? "server_error"
|
|
169
|
+
: "invalid_request_error",
|
|
170
|
+
param: null,
|
|
171
|
+
code: null,
|
|
172
|
+
},
|
|
173
|
+
}), {
|
|
174
|
+
status: response.status,
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
...(retryAfter ? { "Retry-After": retryAfter } : {}),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
183
|
+
if (contentType.includes("text/event-stream") && response.body) {
|
|
184
|
+
const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel);
|
|
185
|
+
if (stream) {
|
|
186
|
+
// Stream failures surface as an error SSE frame then abort the body — never a
|
|
187
|
+
// success completion that embeds `[error] ...` + clean [DONE].
|
|
188
|
+
return new Response(chatSse, {
|
|
189
|
+
status: 200,
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
192
|
+
"Cache-Control": "no-cache",
|
|
193
|
+
Connection: "keep-alive",
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const completion = await collectChatCompletion(chatSse, requestedModel);
|
|
199
|
+
return new Response(JSON.stringify(completion), {
|
|
200
|
+
status: 200,
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (isChatCompletionsStreamError(err)) {
|
|
205
|
+
return chatCompletionsErrorResponse(err.status, err.message, err.type);
|
|
206
|
+
}
|
|
207
|
+
return chatCompletionsErrorResponse(
|
|
208
|
+
502,
|
|
209
|
+
err instanceof Error ? err.message : String(err),
|
|
210
|
+
"server_error",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Defensive: JSON despite stream:true.
|
|
216
|
+
let json: unknown;
|
|
217
|
+
try {
|
|
218
|
+
json = await response.json();
|
|
219
|
+
} catch {
|
|
220
|
+
return chatCompletionsErrorResponse(502, "internal replay returned a non-JSON response", "server_error");
|
|
221
|
+
}
|
|
222
|
+
const status = (json as Rec)?.status;
|
|
223
|
+
if (status === "failed") {
|
|
224
|
+
const error = (json as { error?: { message?: string } }).error;
|
|
225
|
+
return chatCompletionsErrorResponse(502, error?.message ?? "upstream request failed", "server_error");
|
|
226
|
+
}
|
|
227
|
+
const completion = responsesJsonToChatCompletion(json, requestedModel);
|
|
228
|
+
if (!stream) {
|
|
229
|
+
return new Response(JSON.stringify(completion), {
|
|
230
|
+
status: 200,
|
|
231
|
+
headers: { "Content-Type": "application/json" },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Streaming client + JSON upstream: synthesize a minimal Chat Completions stream.
|
|
236
|
+
const encoder = new TextEncoder();
|
|
237
|
+
const id = typeof completion.id === "string" ? completion.id : `chatcmpl-${Date.now()}`;
|
|
238
|
+
const created = typeof completion.created === "number" ? completion.created : Math.floor(Date.now() / 1000);
|
|
239
|
+
const message = isRec((completion.choices as Rec[] | undefined)?.[0])
|
|
240
|
+
? ((completion.choices as Rec[])[0] as Rec).message as Rec | undefined
|
|
241
|
+
: undefined;
|
|
242
|
+
const content = message && typeof message.content === "string" ? message.content : "";
|
|
243
|
+
const frames = [
|
|
244
|
+
`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] })}\n\n`,
|
|
245
|
+
...(content
|
|
246
|
+
? [`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n`]
|
|
247
|
+
: []),
|
|
248
|
+
`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: completion.usage })}\n\n`,
|
|
249
|
+
"data: [DONE]\n\n",
|
|
250
|
+
];
|
|
251
|
+
return new Response(encoder.encode(frames.join("")), {
|
|
252
|
+
status: 200,
|
|
253
|
+
headers: {
|
|
254
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
255
|
+
"Cache-Control": "no-cache",
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
package/src/server/gui-static.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -117,6 +117,7 @@ export {
|
|
|
117
117
|
import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
|
|
118
118
|
export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
|
|
119
119
|
import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages";
|
|
120
|
+
import { handleChatCompletions } from "./chat-completions";
|
|
120
121
|
import { anthropicErrorResponse } from "../claude/outbound";
|
|
121
122
|
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
122
123
|
import { handleImages } from "./images";
|
|
@@ -248,6 +249,9 @@ export function startServer(port?: number) {
|
|
|
248
249
|
}
|
|
249
250
|
|
|
250
251
|
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
252
|
+
// Model discovery never forwards Authorization upstream, so the broader admission
|
|
253
|
+
// set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
|
|
254
|
+
// remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
|
|
251
255
|
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
252
256
|
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
253
257
|
if (!isAllowedRequestOrigin(req, config)) {
|
|
@@ -467,6 +471,25 @@ export function startServer(port?: number) {
|
|
|
467
471
|
return withCors(response, req, config);
|
|
468
472
|
}
|
|
469
473
|
|
|
474
|
+
|
|
475
|
+
// OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients).
|
|
476
|
+
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
477
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
478
|
+
if (isDraining()) {
|
|
479
|
+
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
|
|
480
|
+
}
|
|
481
|
+
const apiAuthError = requireResponsesApiAuth(req, config);
|
|
482
|
+
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
483
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
484
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
|
|
485
|
+
}
|
|
486
|
+
const start = Date.now();
|
|
487
|
+
const requestId = nextRequestLogId(start);
|
|
488
|
+
const logCtx: RequestLogContext = { model: "unknown", provider: "unknown" };
|
|
489
|
+
const response = await handleChatCompletions(req, config, logCtx, { requestId, start });
|
|
490
|
+
return withCors(response, req, config);
|
|
491
|
+
}
|
|
492
|
+
|
|
470
493
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
471
494
|
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
472
495
|
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
@@ -620,6 +643,7 @@ export function startServer(port?: number) {
|
|
|
620
643
|
|
|
621
644
|
console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
|
|
622
645
|
console.log(` POST /v1/responses → provider translation`);
|
|
646
|
+
console.log(` POST /v1/chat/completions → OpenAI-compatible clients`);
|
|
623
647
|
console.log(` GET /healthz → health check`);
|
|
624
648
|
console.log(` GET /api/* → management API`);
|
|
625
649
|
console.log(` GET / → GUI dashboard`);
|
|
@@ -526,6 +526,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
526
526
|
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
527
527
|
hasApiKey: !!p.apiKey,
|
|
528
528
|
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
529
|
+
liveModels: p.liveModels !== false,
|
|
530
|
+
models: p.models ?? [],
|
|
531
|
+
authMode: p.authMode,
|
|
529
532
|
disabled: p.disabled === true,
|
|
530
533
|
codexAccountMode: providerCodexAccountMode(name, p),
|
|
531
534
|
})));
|
|
@@ -673,6 +676,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
673
676
|
touched = true;
|
|
674
677
|
}
|
|
675
678
|
|
|
679
|
+
if (Object.hasOwn(rawBody, "liveModels")) {
|
|
680
|
+
if (typeof rawBody.liveModels !== "boolean") return jsonResponse({ error: "liveModels must be a boolean" }, 400);
|
|
681
|
+
next.liveModels = rawBody.liveModels;
|
|
682
|
+
touched = true;
|
|
683
|
+
}
|
|
684
|
+
|
|
676
685
|
if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
|
|
677
686
|
|
|
678
687
|
// A disabled-only toggle preserves the v2 fast lane: it changes routing eligibility,
|
package/src/server/responses.ts
CHANGED
|
@@ -509,6 +509,31 @@ export function sidecarOutcomeRecorder(
|
|
|
509
509
|
: undefined;
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
/** Codex client hard-coded helper/shadow models: 0.145.0 uses gpt-5.6-luna; older clients gpt-5.4-mini. */
|
|
513
|
+
const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* True when `modelId` is a Codex client shadow/helper source model eligible for the
|
|
517
|
+
* shadowCallIntercept rewrite. Slash-prefixed ids (`openai/gpt-5.6-luna`) are deliberate
|
|
518
|
+
* routed requests, never client shadow calls — hard-excluded even for configured
|
|
519
|
+
* overrides. `configured` arrives unvalidated from disk (config.ts top-level parse is
|
|
520
|
+
* passthrough), so non-string entries are filtered rather than trusted.
|
|
521
|
+
*
|
|
522
|
+
* Known tradeoff (issue #311 review): matching is model-id based, so with the intercept
|
|
523
|
+
* enabled a FOREGROUND bare `gpt-5.6-luna` turn is also rewritten — the same blunt
|
|
524
|
+
* "ALL matching requests" semantics the feature has always documented for gpt-5.4-mini.
|
|
525
|
+
* The proxy has no reliable helper-call signal in the request today; users who run Luna
|
|
526
|
+
* as a foreground model can scope the intercept with `sourceModels: ["gpt-5.4-mini"]`.
|
|
527
|
+
*/
|
|
528
|
+
export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
|
|
529
|
+
if (modelId.includes("/")) return false;
|
|
530
|
+
const configuredStrings = Array.isArray(configured)
|
|
531
|
+
? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
|
|
532
|
+
: [];
|
|
533
|
+
const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
|
|
534
|
+
return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
|
|
535
|
+
}
|
|
536
|
+
|
|
512
537
|
/** Account id to attribute log labels / upstream outcomes to (pool + rotation-injected main). */
|
|
513
538
|
export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
514
539
|
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
@@ -946,9 +971,10 @@ export async function handleResponses(
|
|
|
946
971
|
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
947
972
|
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
948
973
|
|
|
949
|
-
// Shadow call intercept: rewrite Codex
|
|
974
|
+
// Shadow call intercept: rewrite Codex's hard-coded helper calls
|
|
975
|
+
// (gpt-5.4-mini on older clients, gpt-5.6-luna on 0.145.0+)
|
|
950
976
|
const _sci = config.shadowCallIntercept;
|
|
951
|
-
if (_sci?.enabled && _sci.model && parsed.modelId.
|
|
977
|
+
if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
|
|
952
978
|
const _sciOriginal = parsed.modelId;
|
|
953
979
|
parsed.modelId = _sci.model;
|
|
954
980
|
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
package/src/types.ts
CHANGED
|
@@ -482,15 +482,18 @@ export interface OcxConfig {
|
|
|
482
482
|
/** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */
|
|
483
483
|
customModels?: OcxCustomModel[];
|
|
484
484
|
/**
|
|
485
|
-
* Shadow call intercept: redirect Codex
|
|
486
|
-
*
|
|
485
|
+
* Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
|
|
486
|
+
* commit messages, skill orchestration) to a user-chosen model. Default intercepted
|
|
487
|
+
* source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
|
|
487
488
|
* Opt-in; disabled by default. When enabled, effort is forced to low.
|
|
488
489
|
*/
|
|
489
490
|
shadowCallIntercept?: {
|
|
490
|
-
/** When true,
|
|
491
|
+
/** When true, requests for known shadow/helper source models are rewritten to the configured model. */
|
|
491
492
|
enabled?: boolean;
|
|
492
493
|
/** Replacement model id (e.g. "gpt-5.5"). */
|
|
493
494
|
model?: string;
|
|
495
|
+
/** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */
|
|
496
|
+
sourceModels?: string[];
|
|
494
497
|
};
|
|
495
498
|
/**
|
|
496
499
|
* 3-state multi-agent surface override:
|