mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
refactor(executors): extract pure model maps + resolvers from chatgpt-web (#5967)
Extract the static model maps (MODEL_MAP, MODEL_FORCED_EFFORT, THINKING_CAPABLE_SLUGS) and the pure thinking-effort resolvers (isThinkingCapableModel, normalizeThinkingEffort, resolveThinkingEffort, ResolvedChatGptModel, resolveChatGptModel) verbatim into the pure leaf chatgpt-web/models.ts. Host imports the two resolvers it uses back. Host 3205 -> 3076 LOC. Byte-identical bodies (verbatim multiset 120/120), leaf has zero imports (no cycle). Auth/PoW/session/HTTP dispatch and all module caches untouched. Adds a split-guard; consumer tests stay green (chatgpt-web 86, chatgpt-web-tools-5240 4, chatgpt-web-sha3-boringssl-5531 5).
This commit is contained in:
committed by
GitHub
parent
81d640dccc
commit
273e749e6c
@@ -32,6 +32,7 @@ import {
|
||||
__resetChatGptImageCacheForTesting,
|
||||
type ChatGptImageConversationContext,
|
||||
} from "../services/chatgptImageCache.ts";
|
||||
import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,52 +85,6 @@ function deviceIdFor(cookie: string): string {
|
||||
// ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug
|
||||
// catalog comes from /backend-api/models on a logged-in account;
|
||||
// "gpt-5-4-t-mini" is ChatGPT's abbreviated slug for "GPT-5.4 Thinking Mini".
|
||||
const MODEL_MAP: Record<string, string> = {
|
||||
// ChatGPT backend slugs are also accepted directly for power users / tests.
|
||||
"gpt-5-5-pro": "gpt-5-5-pro",
|
||||
"gpt-5-5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5-5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5-5": "gpt-5-5",
|
||||
"gpt-5-4-pro": "gpt-5-4-pro",
|
||||
"gpt-5-4-thinking": "gpt-5-4-thinking",
|
||||
"gpt-5-4-t-mini": "gpt-5-4-t-mini",
|
||||
"gpt-5-3": "gpt-5-3",
|
||||
"gpt-5-3-mini": "gpt-5-3-mini",
|
||||
|
||||
// Public OmniRoute dot-form ids exposed by the provider catalog.
|
||||
"gpt-5.5-pro": "gpt-5-5-pro",
|
||||
"gpt-5.5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5.5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5.5": "gpt-5-5",
|
||||
"gpt-5.4-pro": "gpt-5-4-pro",
|
||||
"gpt-5.4-thinking": "gpt-5-4-thinking",
|
||||
"gpt-5.4-thinking-mini": "gpt-5-4-t-mini",
|
||||
"gpt-5.3-instant": "gpt-5-3-instant",
|
||||
"gpt-5.3": "gpt-5-3",
|
||||
"gpt-5.3-mini": "gpt-5-3-mini",
|
||||
o3: "o3",
|
||||
};
|
||||
|
||||
const MODEL_FORCED_EFFORT: Record<string, "standard" | "extended"> = {
|
||||
"gpt-5-5-pro": "standard",
|
||||
"gpt-5-5-pro-extended": "extended",
|
||||
"gpt-5.5-pro": "standard",
|
||||
"gpt-5.5-pro-extended": "extended",
|
||||
};
|
||||
|
||||
/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint
|
||||
* accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a
|
||||
* new thinking entry there automatically extends this set. Includes the
|
||||
* abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the
|
||||
* reason this set exists at all rather than a substring match.
|
||||
*
|
||||
* Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or
|
||||
* are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */
|
||||
const THINKING_CAPABLE_SLUGS: ReadonlySet<string> = new Set(
|
||||
Object.entries(MODEL_MAP)
|
||||
.filter(([k]) => k.includes("thinking") || k === "o3")
|
||||
.map(([, v]) => v)
|
||||
);
|
||||
|
||||
// ─── Browser-like default headers ──────────────────────────────────────────
|
||||
|
||||
@@ -472,90 +427,6 @@ const thinkingEffortCache = new Map<string, number>();
|
||||
const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000;
|
||||
const THINKING_EFFORT_CACHE_MAX = 400;
|
||||
|
||||
/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking
|
||||
* models and the o-series. PATCHing for a non-thinking surface is a no-op
|
||||
* (the server accepts it but the routing-time read picks the wrong knob).
|
||||
*
|
||||
* Three branches because the input can arrive in three shapes:
|
||||
* 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking
|
||||
* variant carries the literal "thinking" substring here.
|
||||
* 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`).
|
||||
* 3. Resolved chatgpt.com slug that drops the substring under abbreviation
|
||||
* (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is
|
||||
* derived from MODEL_MAP itself so adding a new abbreviated thinking
|
||||
* mapping automatically extends the check.
|
||||
*
|
||||
* Branch 3 also catches the case where a caller passes the chatgpt.com slug
|
||||
* directly as the `model` field (no MODEL_MAP translation needed), which
|
||||
* would otherwise silently bypass the PATCH. */
|
||||
function isThinkingCapableModel(modelId: string, slug: string): boolean {
|
||||
return (
|
||||
modelId.includes("thinking") ||
|
||||
modelId === "o3" ||
|
||||
slug.includes("thinking") ||
|
||||
THINKING_CAPABLE_SLUGS.has(slug) ||
|
||||
THINKING_CAPABLE_SLUGS.has(modelId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Map either a chatgpt.com-native value (`standard`/`extended`) or the
|
||||
* OpenAI Chat Completions `reasoning_effort` field to the value the
|
||||
* `user_last_used_model_config` endpoint expects.
|
||||
*
|
||||
* minimal | low | medium | standard → standard
|
||||
* high | xhigh | extended → extended
|
||||
*
|
||||
* `medium` collapses to `standard` because chatgpt.com only has two levels —
|
||||
* there is no separate medium tier on the web product. Returns null for
|
||||
* absent/unknown inputs. */
|
||||
function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null {
|
||||
if (typeof input !== "string") return null;
|
||||
const v = input.trim().toLowerCase();
|
||||
if (v === "extended" || v === "high" || v === "xhigh") return "extended";
|
||||
if (v === "standard" || v === "low" || v === "medium" || v === "minimal") {
|
||||
return "standard";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the requested effort for this turn.
|
||||
* Order: `providerSpecificData.thinkingEffort` (raw override, takes
|
||||
* `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI
|
||||
* Chat Completions field) > `body.reasoning.effort` (Responses-API nesting).
|
||||
* Returns null when the caller did not request one. */
|
||||
function resolveThinkingEffort(
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
): "standard" | "extended" | null {
|
||||
if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) {
|
||||
return normalizeThinkingEffort(providerSpecificData.thinkingEffort);
|
||||
}
|
||||
const b = (body as Record<string, unknown> | null) ?? null;
|
||||
if (!b) return null;
|
||||
const top = normalizeThinkingEffort(b.reasoning_effort);
|
||||
if (top) return top;
|
||||
const nested = (b.reasoning as Record<string, unknown> | undefined)?.effort;
|
||||
return normalizeThinkingEffort(nested);
|
||||
}
|
||||
|
||||
interface ResolvedChatGptModel {
|
||||
slug: string;
|
||||
effort: "standard" | "extended" | null;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
function resolveChatGptModel(
|
||||
model: string,
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
): ResolvedChatGptModel {
|
||||
const slug = MODEL_MAP[model] ?? model;
|
||||
const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null;
|
||||
const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData);
|
||||
const isPro = slug === "gpt-5-5-pro";
|
||||
return { slug, effort, isPro };
|
||||
}
|
||||
|
||||
function configuredProPollTimeoutMs(): number {
|
||||
const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS;
|
||||
@@ -1399,8 +1270,7 @@ async function* extractContent(
|
||||
// on a tool-role message (handled below).
|
||||
if (event.type === "server_ste_metadata") {
|
||||
const meta = (event as Record<string, unknown>).metadata as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
if (meta && meta.turn_use_case === "image gen") {
|
||||
imageGenAsync = true;
|
||||
}
|
||||
@@ -2780,8 +2650,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
clientHeaders,
|
||||
}: ExecuteInput) {
|
||||
const messages = (body as Record<string, unknown> | null)?.messages as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
Array<Record<string, unknown>> | undefined;
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return {
|
||||
response: errorResponse(400, "Missing or empty messages array"),
|
||||
|
||||
133
open-sse/executors/chatgpt-web/models.ts
Normal file
133
open-sse/executors/chatgpt-web/models.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Pure model-mapping / thinking-effort resolution for the ChatGPT-web executor.
|
||||
// Extracted verbatim from chatgpt-web.ts (static maps + pure resolvers, no state).
|
||||
|
||||
export const MODEL_MAP: Record<string, string> = {
|
||||
// ChatGPT backend slugs are also accepted directly for power users / tests.
|
||||
"gpt-5-5-pro": "gpt-5-5-pro",
|
||||
"gpt-5-5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5-5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5-5": "gpt-5-5",
|
||||
"gpt-5-4-pro": "gpt-5-4-pro",
|
||||
"gpt-5-4-thinking": "gpt-5-4-thinking",
|
||||
"gpt-5-4-t-mini": "gpt-5-4-t-mini",
|
||||
"gpt-5-3": "gpt-5-3",
|
||||
"gpt-5-3-mini": "gpt-5-3-mini",
|
||||
|
||||
// Public OmniRoute dot-form ids exposed by the provider catalog.
|
||||
"gpt-5.5-pro": "gpt-5-5-pro",
|
||||
"gpt-5.5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5.5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5.5": "gpt-5-5",
|
||||
"gpt-5.4-pro": "gpt-5-4-pro",
|
||||
"gpt-5.4-thinking": "gpt-5-4-thinking",
|
||||
"gpt-5.4-thinking-mini": "gpt-5-4-t-mini",
|
||||
"gpt-5.3-instant": "gpt-5-3-instant",
|
||||
"gpt-5.3": "gpt-5-3",
|
||||
"gpt-5.3-mini": "gpt-5-3-mini",
|
||||
o3: "o3",
|
||||
};
|
||||
|
||||
export const MODEL_FORCED_EFFORT: Record<string, "standard" | "extended"> = {
|
||||
"gpt-5-5-pro": "standard",
|
||||
"gpt-5-5-pro-extended": "extended",
|
||||
"gpt-5.5-pro": "standard",
|
||||
"gpt-5.5-pro-extended": "extended",
|
||||
};
|
||||
|
||||
/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint
|
||||
* accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a
|
||||
* new thinking entry there automatically extends this set. Includes the
|
||||
* abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the
|
||||
* reason this set exists at all rather than a substring match.
|
||||
*
|
||||
* Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or
|
||||
* are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */
|
||||
export const THINKING_CAPABLE_SLUGS: ReadonlySet<string> = new Set(
|
||||
Object.entries(MODEL_MAP)
|
||||
.filter(([k]) => k.includes("thinking") || k === "o3")
|
||||
.map(([, v]) => v)
|
||||
);
|
||||
|
||||
/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking
|
||||
* models and the o-series. PATCHing for a non-thinking surface is a no-op
|
||||
* (the server accepts it but the routing-time read picks the wrong knob).
|
||||
*
|
||||
* Three branches because the input can arrive in three shapes:
|
||||
* 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking
|
||||
* variant carries the literal "thinking" substring here.
|
||||
* 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`).
|
||||
* 3. Resolved chatgpt.com slug that drops the substring under abbreviation
|
||||
* (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is
|
||||
* derived from MODEL_MAP itself so adding a new abbreviated thinking
|
||||
* mapping automatically extends the check.
|
||||
*
|
||||
* Branch 3 also catches the case where a caller passes the chatgpt.com slug
|
||||
* directly as the `model` field (no MODEL_MAP translation needed), which
|
||||
* would otherwise silently bypass the PATCH. */
|
||||
export function isThinkingCapableModel(modelId: string, slug: string): boolean {
|
||||
return (
|
||||
modelId.includes("thinking") ||
|
||||
modelId === "o3" ||
|
||||
slug.includes("thinking") ||
|
||||
THINKING_CAPABLE_SLUGS.has(slug) ||
|
||||
THINKING_CAPABLE_SLUGS.has(modelId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Map either a chatgpt.com-native value (`standard`/`extended`) or the
|
||||
* OpenAI Chat Completions `reasoning_effort` field to the value the
|
||||
* `user_last_used_model_config` endpoint expects.
|
||||
*
|
||||
* minimal | low | medium | standard → standard
|
||||
* high | xhigh | extended → extended
|
||||
*
|
||||
* `medium` collapses to `standard` because chatgpt.com only has two levels —
|
||||
* there is no separate medium tier on the web product. Returns null for
|
||||
* absent/unknown inputs. */
|
||||
export function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null {
|
||||
if (typeof input !== "string") return null;
|
||||
const v = input.trim().toLowerCase();
|
||||
if (v === "extended" || v === "high" || v === "xhigh") return "extended";
|
||||
if (v === "standard" || v === "low" || v === "medium" || v === "minimal") {
|
||||
return "standard";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the requested effort for this turn.
|
||||
* Order: `providerSpecificData.thinkingEffort` (raw override, takes
|
||||
* `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI
|
||||
* Chat Completions field) > `body.reasoning.effort` (Responses-API nesting).
|
||||
* Returns null when the caller did not request one. */
|
||||
export function resolveThinkingEffort(
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
): "standard" | "extended" | null {
|
||||
if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) {
|
||||
return normalizeThinkingEffort(providerSpecificData.thinkingEffort);
|
||||
}
|
||||
const b = (body as Record<string, unknown> | null) ?? null;
|
||||
if (!b) return null;
|
||||
const top = normalizeThinkingEffort(b.reasoning_effort);
|
||||
if (top) return top;
|
||||
const nested = (b.reasoning as Record<string, unknown> | undefined)?.effort;
|
||||
return normalizeThinkingEffort(nested);
|
||||
}
|
||||
|
||||
export interface ResolvedChatGptModel {
|
||||
slug: string;
|
||||
effort: "standard" | "extended" | null;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function resolveChatGptModel(
|
||||
model: string,
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
): ResolvedChatGptModel {
|
||||
const slug = MODEL_MAP[model] ?? model;
|
||||
const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null;
|
||||
const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData);
|
||||
const isPro = slug === "gpt-5-5-pro";
|
||||
return { slug, effort, isPro };
|
||||
}
|
||||
35
tests/unit/chatgpt-web-models-split.test.ts
Normal file
35
tests/unit/chatgpt-web-models-split.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Split-guard for the chatgpt-web model-mapping extraction.
|
||||
// The static model maps + pure thinking-effort resolvers live in the pure leaf
|
||||
// chatgpt-web/models.ts (no module state). Host imports the two it uses back.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const EXE = join(HERE, "../../open-sse/executors");
|
||||
const HOST = join(EXE, "chatgpt-web.ts");
|
||||
const LEAF = join(EXE, "chatgpt-web/models.ts");
|
||||
|
||||
test("leaf hosts the model maps + resolvers and does not import the host", () => {
|
||||
const src = readFileSync(LEAF, "utf8");
|
||||
for (const sym of ["MODEL_MAP", "resolveChatGptModel", "resolveThinkingEffort"]) {
|
||||
assert.match(src, new RegExp(`export (const|function) ${sym}\\b`));
|
||||
}
|
||||
assert.doesNotMatch(src, /from "\.\.\/chatgpt-web\.ts"/);
|
||||
});
|
||||
|
||||
test("host imports the resolvers back from the leaf", () => {
|
||||
const host = readFileSync(HOST, "utf8");
|
||||
assert.match(host, /from "\.\/chatgpt-web\/models\.ts"/);
|
||||
});
|
||||
|
||||
test("resolveChatGptModel maps a dot-form model id to a chatgpt slug", async () => {
|
||||
const { resolveChatGptModel, MODEL_MAP } =
|
||||
await import("../../open-sse/executors/chatgpt-web/models.ts");
|
||||
const firstKey = Object.keys(MODEL_MAP)[0];
|
||||
const resolved = resolveChatGptModel(firstKey);
|
||||
assert.equal(typeof resolved.slug, "string");
|
||||
assert.ok(resolved.slug.length > 0);
|
||||
});
|
||||
Reference in New Issue
Block a user