@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.
@@ -11,15 +11,24 @@ export type WhamUsageResponse = {
11
11
  email?: string | null;
12
12
  plan_type?: string | null;
13
13
  rate_limit?: {
14
- primary_window?: { used_percent?: number; reset_at?: number };
15
- secondary_window?: { used_percent?: number; reset_at?: number };
16
- tertiary_window?: { used_percent?: number; reset_at?: number };
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
- // primary_window was the 5h window; it now carries weekly data for GPT plans.
114
- // secondary_window is the legacy weekly source; prefer primary when present.
115
- const primaryPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
116
- const secondaryPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
117
- const weeklyPercent = primaryPercent ?? secondaryPercent;
118
- const monthlyPercent = normalizeUsagePercent(data.rate_limit.tertiary_window?.used_percent);
119
- const primaryResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
120
- const secondaryResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
121
- const weeklyResetAt = primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
122
- const monthlyResetAt = normalizeResetAt(data.rate_limit.tertiary_window?.reset_at);
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
@@ -19,3 +19,4 @@ export type {
19
19
  OcxTool,
20
20
  AdapterEvent,
21
21
  } from "./types";
22
+ // release-train: preview publish gate for v2.7.35-preview.20260723
@@ -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
- // Anthropic adaptive thinking supports low/medium/high/max for both Sonnet 4.6 and Opus 4.6.
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
+ }
@@ -92,6 +92,7 @@ export function rootFallbackPayload() {
92
92
  health: "/healthz",
93
93
  models: "/v1/models",
94
94
  responses: "/v1/responses",
95
+ chatCompletions: "/v1/chat/completions",
95
96
  management: "/api/*",
96
97
  },
97
98
  };
@@ -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,
@@ -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 Desktop's hard-coded gpt-5.4-mini helper calls
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.startsWith("gpt-5.4-mini")) {
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 Desktop's hard-coded gpt-5.4-mini helper calls
486
- * (title generation, commit messages, skill orchestration) to a user-chosen model.
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, all gpt-5.4-mini* requests are rewritten to the configured model. */
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: