mirror of
https://github.com/hanzoai/opencode-openai-codex-auth.git
synced 2026-09-24 17:01:02 +00:00
307 lines
9.5 KiB
TypeScript
307 lines
9.5 KiB
TypeScript
/**
|
|
* Helper functions for the custom fetch implementation
|
|
* These functions break down the complex fetch logic into manageable, testable units
|
|
*/
|
|
|
|
import type { Auth } from "@opencode-ai/sdk";
|
|
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
import { refreshAccessToken } from "../auth/auth.js";
|
|
import { logRequest } from "../logger.js";
|
|
import { transformRequestBody } from "./request-transformer.js";
|
|
import { convertSseToJson, ensureContentType } from "./response-handler.js";
|
|
import type { UserConfig, RequestBody } from "../types.js";
|
|
import {
|
|
PLUGIN_NAME,
|
|
HTTP_STATUS,
|
|
OPENAI_HEADERS,
|
|
OPENAI_HEADER_VALUES,
|
|
URL_PATHS,
|
|
ERROR_MESSAGES,
|
|
LOG_STAGES,
|
|
} from "../constants.js";
|
|
|
|
/**
|
|
* Determines if the current auth token needs to be refreshed
|
|
* @param auth - Current authentication state
|
|
* @returns True if token is expired or invalid
|
|
*/
|
|
export function shouldRefreshToken(auth: Auth): boolean {
|
|
return auth.type !== "oauth" || !auth.access || auth.expires < Date.now();
|
|
}
|
|
|
|
/**
|
|
* Refreshes the OAuth token and updates stored credentials
|
|
* @param currentAuth - Current auth state
|
|
* @param client - Opencode client for updating stored credentials
|
|
* @returns Updated auth or error response
|
|
*/
|
|
export async function refreshAndUpdateToken(
|
|
currentAuth: Auth,
|
|
client: OpencodeClient,
|
|
): Promise<
|
|
{ success: true; auth: Auth } | { success: false; response: Response }
|
|
> {
|
|
const refreshToken = currentAuth.type === "oauth" ? currentAuth.refresh : "";
|
|
const refreshResult = await refreshAccessToken(refreshToken);
|
|
|
|
if (refreshResult.type === "failed") {
|
|
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
|
|
return {
|
|
success: false,
|
|
response: new Response(
|
|
JSON.stringify({ error: "Token refresh failed" }),
|
|
{ status: HTTP_STATUS.UNAUTHORIZED },
|
|
),
|
|
};
|
|
}
|
|
|
|
// Update stored credentials
|
|
await client.auth.set({
|
|
path: { id: "openai" },
|
|
body: {
|
|
type: "oauth",
|
|
access: refreshResult.access,
|
|
refresh: refreshResult.refresh,
|
|
expires: refreshResult.expires,
|
|
},
|
|
});
|
|
|
|
// Update current auth reference if it's OAuth type
|
|
if (currentAuth.type === "oauth") {
|
|
currentAuth.access = refreshResult.access;
|
|
currentAuth.refresh = refreshResult.refresh;
|
|
currentAuth.expires = refreshResult.expires;
|
|
}
|
|
|
|
return { success: true, auth: currentAuth };
|
|
}
|
|
|
|
/**
|
|
* Extracts URL string from various request input types
|
|
* @param input - Request input (string, URL, or Request object)
|
|
* @returns URL string
|
|
*/
|
|
export function extractRequestUrl(input: Request | string | URL): string {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.toString();
|
|
return input.url;
|
|
}
|
|
|
|
/**
|
|
* Rewrites OpenAI API URLs to Codex backend URLs
|
|
* @param url - Original URL
|
|
* @returns Rewritten URL for Codex backend
|
|
*/
|
|
export function rewriteUrlForCodex(url: string): string {
|
|
return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
|
|
}
|
|
|
|
/**
|
|
* Transforms request body and logs the transformation
|
|
* @param init - Request init options
|
|
* @param url - Request URL
|
|
* @param codexInstructions - Codex system instructions
|
|
* @param userConfig - User configuration
|
|
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap)
|
|
* @returns Transformed body and updated init, or undefined if no body
|
|
*/
|
|
export async function transformRequestForCodex(
|
|
init: RequestInit | undefined,
|
|
url: string,
|
|
codexInstructions: string,
|
|
userConfig: UserConfig,
|
|
codexMode = true,
|
|
): Promise<{ body: RequestBody; updatedInit: RequestInit } | undefined> {
|
|
if (!init?.body) return undefined;
|
|
|
|
try {
|
|
const body = JSON.parse(init.body as string) as RequestBody;
|
|
const originalModel = body.model;
|
|
|
|
// Log original request
|
|
logRequest(LOG_STAGES.BEFORE_TRANSFORM, {
|
|
url,
|
|
originalModel,
|
|
model: body.model,
|
|
hasTools: !!body.tools,
|
|
hasInput: !!body.input,
|
|
inputLength: body.input?.length,
|
|
codexMode,
|
|
body: body as unknown as Record<string, unknown>,
|
|
});
|
|
|
|
// Transform request body
|
|
const transformedBody = await transformRequestBody(
|
|
body,
|
|
codexInstructions,
|
|
userConfig,
|
|
codexMode,
|
|
);
|
|
|
|
// Log transformed request
|
|
logRequest(LOG_STAGES.AFTER_TRANSFORM, {
|
|
url,
|
|
originalModel,
|
|
normalizedModel: transformedBody.model,
|
|
hasTools: !!transformedBody.tools,
|
|
hasInput: !!transformedBody.input,
|
|
inputLength: transformedBody.input?.length,
|
|
reasoning: transformedBody.reasoning as unknown,
|
|
textVerbosity: transformedBody.text?.verbosity,
|
|
include: transformedBody.include,
|
|
body: transformedBody as unknown as Record<string, unknown>,
|
|
});
|
|
|
|
return {
|
|
body: transformedBody,
|
|
updatedInit: { ...init, body: JSON.stringify(transformedBody) },
|
|
};
|
|
} catch (e) {
|
|
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.REQUEST_PARSE_ERROR}:`, e);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates headers for Codex API requests
|
|
* @param init - Request init options
|
|
* @param accountId - ChatGPT account ID
|
|
* @param accessToken - OAuth access token
|
|
* @returns Headers object with all required Codex headers
|
|
*/
|
|
export function createCodexHeaders(
|
|
init: RequestInit | undefined,
|
|
accountId: string,
|
|
accessToken: string,
|
|
opts?: { model?: string; promptCacheKey?: string },
|
|
): Headers {
|
|
const headers = new Headers(init?.headers ?? {});
|
|
headers.delete("x-api-key"); // Remove any existing API key
|
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
|
|
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
|
|
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
|
|
|
const cacheKey = opts?.promptCacheKey;
|
|
if (cacheKey) {
|
|
headers.set(OPENAI_HEADERS.CONVERSATION_ID, cacheKey);
|
|
headers.set(OPENAI_HEADERS.SESSION_ID, cacheKey);
|
|
} else {
|
|
headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
|
|
headers.delete(OPENAI_HEADERS.SESSION_ID);
|
|
}
|
|
headers.set("accept", "text/event-stream");
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Handles error responses from the Codex API
|
|
* @param response - Error response from API
|
|
* @returns Response with error details
|
|
*/
|
|
export async function handleErrorResponse(
|
|
response: Response,
|
|
): Promise<Response> {
|
|
const raw = await response.text();
|
|
|
|
let enriched = raw;
|
|
try {
|
|
const parsed = JSON.parse(raw) as any;
|
|
const err = parsed?.error ?? {};
|
|
|
|
// Parse Codex rate-limit headers if present
|
|
const h = response.headers;
|
|
const primary = {
|
|
used_percent: toNumber(h.get("x-codex-primary-used-percent")),
|
|
window_minutes: toInt(h.get("x-codex-primary-window-minutes")),
|
|
resets_at: toInt(h.get("x-codex-primary-reset-at")),
|
|
};
|
|
const secondary = {
|
|
used_percent: toNumber(h.get("x-codex-secondary-used-percent")),
|
|
window_minutes: toInt(h.get("x-codex-secondary-window-minutes")),
|
|
resets_at: toInt(h.get("x-codex-secondary-reset-at")),
|
|
};
|
|
const rate_limits =
|
|
primary.used_percent !== undefined || secondary.used_percent !== undefined
|
|
? { primary, secondary }
|
|
: undefined;
|
|
|
|
// Friendly message for subscription/rate usage limits
|
|
const code = (err.code ?? err.type ?? "").toString();
|
|
const resetsAt = err.resets_at ?? primary.resets_at ?? secondary.resets_at;
|
|
const mins = resetsAt ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) : undefined;
|
|
let friendly_message: string | undefined;
|
|
if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
|
|
const plan = err.plan_type ? ` (${String(err.plan_type).toLowerCase()} plan)` : "";
|
|
const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
|
|
friendly_message = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
|
|
}
|
|
|
|
const enhanced = {
|
|
error: {
|
|
...err,
|
|
message: err.message ?? friendly_message ?? "Usage limit reached.",
|
|
friendly_message,
|
|
rate_limits,
|
|
status: response.status,
|
|
},
|
|
};
|
|
enriched = JSON.stringify(enhanced);
|
|
} catch {
|
|
// Raw body not JSON; leave unchanged
|
|
enriched = raw;
|
|
}
|
|
|
|
console.error(`[${PLUGIN_NAME}] ${response.status} error:`, enriched);
|
|
logRequest(LOG_STAGES.ERROR_RESPONSE, {
|
|
status: response.status,
|
|
error: enriched,
|
|
});
|
|
|
|
const headers = new Headers(response.headers);
|
|
headers.set("content-type", "application/json; charset=utf-8");
|
|
return new Response(enriched, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles successful responses from the Codex API
|
|
* Converts SSE to JSON for non-tool requests
|
|
* @param response - Success response from API
|
|
* @param hasTools - Whether the request included tools
|
|
* @returns Processed response (SSE→JSON for non-tool, stream for tool requests)
|
|
*/
|
|
export async function handleSuccessResponse(
|
|
response: Response,
|
|
hasTools: boolean,
|
|
): Promise<Response> {
|
|
const responseHeaders = ensureContentType(response.headers);
|
|
|
|
// For non-tool requests (compact/summarize), convert streaming SSE to JSON
|
|
// generateText() expects a non-streaming JSON response, not SSE
|
|
if (!hasTools) {
|
|
return await convertSseToJson(response, responseHeaders);
|
|
}
|
|
|
|
// For tool requests, return stream as-is (streamText handles SSE)
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
function toNumber(v: string | null): number | undefined {
|
|
if (v == null) return undefined;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
function toInt(v: string | null): number | undefined {
|
|
if (v == null) return undefined;
|
|
const n = parseInt(v, 10);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|