diff --git a/README.md b/README.md index c8145c6..6e14868 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ ## Getting Started -1. **Set Keys:** - - Add `OPENAI_API_KEY` or `TOGETHER_API_KEY` in `www/config.env`. +1. **Connect OpenAI (ChatGPT subscription):** + - Start the app and click "Connect OpenAI" in the UI to authorize via OAuth. + - This uses the same Codex OAuth flow as OpenCode. + - Make sure port 1455 is available for the local OAuth callback. 2. **Problems Setup:** - Put your Hacker Cup-format problems into `./PROBLEMS/` (see examples) @@ -54,7 +56,7 @@ 5. **Models & Config:** - - Default model: GPT-4o-mini + - Default model: Codex (ChatGPT subscription) - See `www/app/config.ts` to: - Switch between different LLM models - Adjust agent settings and parameters diff --git a/www/.gitignore b/www/.gitignore index e8fbbda..f02c01a 100644 --- a/www/.gitignore +++ b/www/.gitignore @@ -1,3 +1,4 @@ .next/ config.env -node_modules \ No newline at end of file +node_modules +.stackfish/ diff --git a/www/app/api/openai/authorize/route.ts b/www/app/api/openai/authorize/route.ts new file mode 100644 index 0000000..6180f89 --- /dev/null +++ b/www/app/api/openai/authorize/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { startOpenAIAuthorize } from '../../../services/openaiOAuth'; + +export async function POST() { + try { + const authorization = await startOpenAIAuthorize(); + return NextResponse.json(authorization); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start OpenAI authorization'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/www/app/api/openai/logout/route.ts b/www/app/api/openai/logout/route.ts new file mode 100644 index 0000000..3c50f7c --- /dev/null +++ b/www/app/api/openai/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { clearOpenAIAuth } from '../../../services/openaiOAuth'; + +export async function POST() { + await clearOpenAIAuth(); + return NextResponse.json({ success: true }); +} diff --git a/www/app/api/openai/status/route.ts b/www/app/api/openai/status/route.ts new file mode 100644 index 0000000..5b2ac0d --- /dev/null +++ b/www/app/api/openai/status/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; +import { getOpenAIAuthStatus } from '../../../services/openaiOAuth'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const status = await getOpenAIAuthStatus(); + return NextResponse.json(status); +} diff --git a/www/app/config/config.ts b/www/app/config/config.ts index 0165d96..c4b96e7 100644 --- a/www/app/config/config.ts +++ b/www/app/config/config.ts @@ -4,34 +4,40 @@ import { Model } from '../types/models'; // RECOMMENDED:specify yours CLOUD EXECUTE URL after deploying ./cloud-run-worker to google cloud run export const CLOUD_EXECUTE_URL = "https://cloud-run-worker-313568160682.us-central1.run.app/compute"; -// If use OpenAI models, please specify OPENAI_API_KEY= in config.env file +// OpenAI models use ChatGPT subscription OAuth (Codex) // If use qwq or llama models, please specify together.ai TOGETHER_API_KEY= in config.env file // Defines how many parallel LLM calls to launch to generate synthetic tests for a problem export const syntheticTestCallsPerModel: Record = { - 'o1-preview': 0, - 'o1-mini': 0, - 'gpt-4o': 0, - 'gpt-4o-mini': 1, + 'gpt-5.3-codex': 1, + 'gpt-5.2-codex': 0, + 'gpt-5.2': 0, + 'gpt-5.1-codex': 0, + 'gpt-5.1-codex-mini': 0, + 'gpt-5.1-codex-max': 0, 'qwq-32b-preview': 0, 'llama-3.3-70b': 0, } export const postSyntheticTestCallsPerModel: Record = { - 'o1-preview': 0, - 'o1-mini': 0, - 'gpt-4o': 0, - 'gpt-4o-mini': 1, + 'gpt-5.3-codex': 1, + 'gpt-5.2-codex': 0, + 'gpt-5.2': 0, + 'gpt-5.1-codex': 0, + 'gpt-5.1-codex-mini': 0, + 'gpt-5.1-codex-max': 0, 'qwq-32b-preview': 0, 'llama-3.3-70b': 0, } // Defines how many parallel LLM calls to launch to generate a hypothesis (aka. attack vector) export const attackVectorCallsPerModel: Record = { - 'o1-preview': 0, - 'o1-mini': 0, - 'gpt-4o': 0, - 'gpt-4o-mini': 1, + 'gpt-5.3-codex': 1, + 'gpt-5.2-codex': 0, + 'gpt-5.2': 0, + 'gpt-5.1-codex': 0, + 'gpt-5.1-codex-mini': 0, + 'gpt-5.1-codex-max': 0, 'qwq-32b-preview': 0, 'llama-3.3-70b': 0, } @@ -39,10 +45,12 @@ export const attackVectorCallsPerModel: Record = { // Defines how many parallel agents to launch to: // After hypothesis is generated, how many agents should write a solution code export const postAttackVectorSolutionCallsPerModel: Record = { - 'o1-preview': 0, - 'o1-mini': 0, - 'gpt-4o': 0, - 'gpt-4o-mini': 1, + 'gpt-5.3-codex': 1, + 'gpt-5.2-codex': 0, + 'gpt-5.2': 0, + 'gpt-5.1-codex': 0, + 'gpt-5.1-codex-mini': 0, + 'gpt-5.1-codex-max': 0, 'qwq-32b-preview': 0, 'llama-3.3-70b': 0, } @@ -50,10 +58,12 @@ export const postAttackVectorSolutionCallsPerModel: Record = { // Defines how many parallel agents to launch to: // Generate a solution directly, without a hypothesis and tests steps export const directSolutionCallsPerModel: Record = { - 'o1-preview': 0, - 'o1-mini': 0, - 'gpt-4o': 0, - 'gpt-4o-mini': 0, + 'gpt-5.3-codex': 0, + 'gpt-5.2-codex': 0, + 'gpt-5.2': 0, + 'gpt-5.1-codex': 0, + 'gpt-5.1-codex-mini': 0, + 'gpt-5.1-codex-max': 0, 'qwq-32b-preview': 0, 'llama-3.3-70b': 0, } @@ -64,14 +74,14 @@ export const directSolutionCallsPerModel: Record = { // If only one output is possible, we can verify test cases by simply comparing the strings // If more than one output is possible, we need to use LLM to verify if our solution is correct // The IS_ONLY_ONE_OUTPUT_VALID_MODEL model reads the problem statement and determines if only one output is possible -export const IS_ONLY_ONE_OUTPUT_VALID_MODEL: Model = 'gpt-4o'; +export const IS_ONLY_ONE_OUTPUT_VALID_MODEL: Model = 'gpt-5.3-codex'; // In case if multiple outputs are possible, we use LLM to guess if the provided output seems correct -export const IS_VALID_OUTPUT_MODEL: Model = 'gpt-4o'; +export const IS_VALID_OUTPUT_MODEL: Model = 'gpt-5.3-codex'; // After a hypothesis is generated, we use LLM to extract knowledge tags - advanced algorithms and data structures, // which are mentioned in the hypothesis -export const EXTRACT_KNOWLEDGE_TAGS_MODEL: Model = 'gpt-4o'; +export const EXTRACT_KNOWLEDGE_TAGS_MODEL: Model = 'gpt-5.3-codex'; // A dir with problem statements in hackercup format. Each problem is in a separate dir. // Files required in each problem dir: statement.txt, sample_in.txt, sample_out.txt, full_in.txt @@ -80,4 +90,3 @@ export const PROBLEMS_PATH = path.join(process.cwd(), '..', 'PROBLEMS'); // A dir where the model will place verified solutions // For every problem a separate dir is created, and both source code and full test cases are placed there export const SOLUTIONS_PATH = path.join(process.cwd(), '..', 'SOLUTIONS'); - diff --git a/www/app/page.tsx b/www/app/page.tsx index 17ef036..d319b57 100644 --- a/www/app/page.tsx +++ b/www/app/page.tsx @@ -1,13 +1,17 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import ProblemColumn from '@/components/ProblemColumn'; -import Image from 'next/image'; import { ProblemService } from './services/problemService'; export default function Home() { const [problems, setProblems] = useState([]); const [problemRequests, setProblemRequests] = useState>({}); + const [authStatus, setAuthStatus] = useState<{ status: string; connected: boolean; error?: string; accountId?: string }>({ + status: 'idle', + connected: false, + }); + const pollRef = useRef | null>(null); useEffect(() => { const fetchProblems = async () => { @@ -32,6 +36,56 @@ export default function Home() { }; }, []); + const refreshAuth = async () => { + try { + const response = await fetch('/api/openai/status'); + const data = await response.json(); + setAuthStatus(data); + if (data.status !== 'pending' && pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } catch { + setAuthStatus((prev) => ({ ...prev, status: 'error', error: 'Failed to read auth status' })); + } + }; + + useEffect(() => { + refreshAuth(); + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + } + }; + }, []); + + const startAuth = async () => { + setAuthStatus((prev) => ({ ...prev, status: 'pending', error: undefined })); + try { + const response = await fetch('/api/openai/authorize', { method: 'POST' }); + const data = await response.json(); + if (data?.error) { + setAuthStatus((prev) => ({ ...prev, status: 'error', error: data.error })); + return; + } + if (data?.url) { + window.open(data.url, '_blank', 'noopener,noreferrer'); + } + if (!pollRef.current) { + pollRef.current = setInterval(() => { + refreshAuth(); + }, 1000); + } + } catch { + setAuthStatus((prev) => ({ ...prev, status: 'error', error: 'Failed to start OAuth' })); + } + }; + + const disconnect = async () => { + await fetch('/api/openai/logout', { method: 'POST' }); + await refreshAuth(); + }; + // Calculate totals const totalLLM = Object.values(problemRequests).reduce((sum, curr) => sum + curr.llm, 0); const totalCompute = Object.values(problemRequests).reduce((sum, curr) => sum + curr.compute, 0); @@ -45,7 +99,27 @@ export default function Home() { 🐟 STACKFISH -
+
+
+ + OpenAI: {authStatus.connected ? 'Connected' : authStatus.status === 'pending' ? 'Connecting' : 'Not connected'} + + {authStatus.connected ? ( + + ) : ( + + )} +
0 ? 'bg-green-500 animate-pulse' : 'bg-gray-500'}`} /> diff --git a/www/app/services/llm.ts b/www/app/services/llm.ts index 535feba..85ea365 100644 --- a/www/app/services/llm.ts +++ b/www/app/services/llm.ts @@ -1,15 +1,146 @@ -import OpenAI from "openai"; import dotenv from "dotenv"; import { Model } from '../types/models'; import Together from 'together-ai'; import * as prompts from './prompts'; import { parseJson } from './parse_utils'; +import { CODEX_API_ENDPOINT, ensureOpenAIAuth } from './openaiOAuth'; +import { v4 as uuidv4 } from 'uuid'; dotenv.config({ path: "./config.env" }); type Message = Together.Chat.Completions.CompletionCreateParams.Message | OpenAI.Chat.ChatCompletionMessageParam; +function messageText(message: Message): string { + const content = message.content; + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + const parts = content + .map((part) => { + if (typeof (part as { text?: string }).text === 'string') { + return (part as { text: string }).text; + } + return ''; + }) + .filter(Boolean); + if (parts.length > 0) { + return parts.join(''); + } + } + return JSON.stringify(content); +} + +function buildResponseInput(messages: Message[], isJson: boolean) { + const instructions: string[] = ['You are a helpful assistant.']; + const items = messages + .map((message) => { + const text = messageText(message).trim(); + if (!text) { + return undefined; + } + if (message.role === 'system' || message.role === 'developer') { + instructions.push(text); + return undefined; + } + if (message.role === 'assistant') { + return { + type: 'message' as const, + role: 'user' as const, + content: [{ type: 'input_text' as const, text: `assistant: ${text}` }], + }; + } + return { + type: 'message' as const, + role: 'user' as const, + content: [{ type: 'input_text' as const, text }], + }; + }) + .filter(Boolean); + + if (isJson) { + instructions.push('Return valid JSON only.'); + } + + return { input: items, instructions: instructions.join('\n\n') }; +} + +async function callCodex(model: string, payload: ReturnType, auth: { access: string; accountId?: string }) { + const response = await fetch(CODEX_API_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${auth.access}`, + 'User-Agent': 'stackfish/0.1.0', + session_id: uuidv4(), + ...(auth.accountId ? { 'ChatGPT-Account-Id': auth.accountId } : {}), + }, + body: JSON.stringify({ + model, + input: payload.input, + instructions: payload.instructions, + store: false, + stream: true, + }), + }); + + if (!response.ok) { + const text = await response.text(); + const message = text ? `${response.status} ${text}` : `${response.status}`; + const error = new Error(message); + (error as Error & { status?: number }).status = response.status; + throw error; + } + + if (!response.body) { + return ''; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let output = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + + for (const part of parts) { + const lines = part.split('\n'); + const dataLines = lines.filter((line) => line.startsWith('data:')); + if (dataLines.length === 0) { + continue; + } + const data = dataLines.map((line) => line.slice(5).trim()).join('\n'); + if (!data || data === '[DONE]') { + continue; + } + try { + const event = JSON.parse(data); + if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') { + output += event.delta; + } + if (!output && event?.type === 'response.output_text.done' && typeof event.text === 'string') { + output = event.text; + } + if (!output && event?.response?.output_text) { + output = event.response.output_text; + } + } catch { + continue; + } + } + } + + return output; +} + async function llm(messages: string | Message[], model: Model, isJson: boolean = false): Promise { // Convert string input to proper message format const formattedMessages = typeof messages === 'string' @@ -44,13 +175,39 @@ async function llm(messages: string | Message[], model: Model, isJson: boolean = } return content; } else { - const client = new OpenAI({apiKey: process.env.OPENAI_API_KEY}); - const response = await client.chat.completions.create({ - model: model, - messages: formattedMessages as OpenAI.Chat.ChatCompletionMessageParam[], - response_format: isJson ? { type: "json_object" } : undefined, - }); - return response.choices[0].message.content || ''; + const auth = await ensureOpenAIAuth(); + if (!auth) { + throw new Error('OpenAI OAuth is not connected. Connect ChatGPT subscription to use Codex models.'); + } + const payload = buildResponseInput(formattedMessages as Message[], isJson); + const fallbackModels = Array.from(new Set([ + model, + 'gpt-5.3-codex', + 'gpt-5.2-codex', + 'gpt-5.1-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + ])); + + let lastError: Error | undefined; + for (const candidate of fallbackModels) { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await callCodex(candidate, payload, auth); + } catch (error) { + const err = error as Error & { status?: number }; + lastError = err; + if (err.status && err.status !== 400 && err.status !== 429 && err.status !== 500 && err.status !== 503) { + throw err; + } + if (err.status === 400) { + break; + } + } + } + } + + throw lastError || new Error('Codex request failed'); } } catch (error) { console.error("Error in LLM call:", error); @@ -58,4 +215,4 @@ async function llm(messages: string | Message[], model: Model, isJson: boolean = } } -export default llm; \ No newline at end of file +export default llm; diff --git a/www/app/services/openaiOAuth.ts b/www/app/services/openaiOAuth.ts new file mode 100644 index 0000000..702cf9c --- /dev/null +++ b/www/app/services/openaiOAuth.ts @@ -0,0 +1,439 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import http from "http"; +import path from "path"; + +export const OPENAI_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +export const OPENAI_ISSUER = "https://auth.openai.com"; +export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; +export const OPENAI_OAUTH_PORT = 1455; + +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; + +type OAuthState = { + status: "idle" | "pending" | "success" | "error"; + error?: string; +}; + +type PkceCodes = { + verifier: string; + challenge: string; +}; + +type TokenResponse = { + id_token: string; + access_token: string; + refresh_token: string; + expires_in?: number; +}; + +type OpenAIAuth = { + type: "oauth"; + access: string; + refresh: string; + expires: number; + accountId?: string; +}; + +type PendingOAuth = { + pkce: PkceCodes; + state: string; + timeout: NodeJS.Timeout; +}; + +let server: http.Server | undefined; +let pending: PendingOAuth | undefined; +let oauthState: OAuthState = { status: "idle" }; + +function authPath() { + return path.join(process.cwd(), ".stackfish", "auth.json"); +} + +function base64UrlEncode(buffer: ArrayBuffer | Buffer): string { + const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + return data + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function generateRandomString(length: number): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const bytes = crypto.randomBytes(length); + return Array.from(bytes) + .map((b) => chars[b % chars.length]) + .join(""); +} + +async function generatePKCE(): Promise { + const verifier = generateRandomString(43); + const hash = crypto.createHash("sha256").update(verifier).digest(); + const challenge = base64UrlEncode(hash); + return { verifier, challenge }; +} + +function generateState(): string { + return base64UrlEncode(crypto.randomBytes(32)); +} + +function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: OPENAI_OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + }); + return `${OPENAI_ISSUER}/oauth/authorize?${params.toString()}`; +} + +export type IdTokenClaims = { + chatgpt_account_id?: string; + organizations?: Array<{ id: string }>; + email?: string; + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string; + }; +}; + +function parseJwtClaims(token: string): IdTokenClaims | undefined { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString()); + } catch { + return undefined; + } +} + +function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { + return ( + claims.chatgpt_account_id || + claims["https://api.openai.com/auth"]?.chatgpt_account_id || + claims.organizations?.[0]?.id + ); +} + +function extractAccountId(tokens: TokenResponse): string | undefined { + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token); + const accountId = claims && extractAccountIdFromClaims(claims); + if (accountId) return accountId; + } + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token); + return claims ? extractAccountIdFromClaims(claims) : undefined; + } + return undefined; +} + +async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { + const response = await fetch(`${OPENAI_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: OPENAI_OAUTH_CLIENT_ID, + code_verifier: pkce.verifier, + }).toString(), + }); + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.status}`); + } + return response.json(); +} + +async function refreshAccessToken(refreshToken: string): Promise { + const response = await fetch(`${OPENAI_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OPENAI_OAUTH_CLIENT_ID, + }).toString(), + }); + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.status}`); + } + return response.json(); +} + +async function readAuth(): Promise { + try { + const data = await fs.readFile(authPath(), "utf8"); + const parsed = JSON.parse(data); + if (parsed?.type === "oauth" && parsed.access && parsed.refresh && parsed.expires) { + return parsed as OpenAIAuth; + } + return undefined; + } catch { + return undefined; + } +} + +async function writeAuth(auth: OpenAIAuth) { + const dir = path.dirname(authPath()); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(authPath(), JSON.stringify(auth, null, 2), "utf8"); +} + +export async function clearOpenAIAuth() { + try { + await fs.unlink(authPath()); + } catch {} + oauthState = { status: "idle" }; +} + +export async function ensureOpenAIAuth(): Promise { + const auth = await readAuth(); + if (!auth) return undefined; + if (!auth.access || auth.expires < Date.now()) { + const tokens = await refreshAccessToken(auth.refresh); + const accountId = extractAccountId(tokens) || auth.accountId; + const updated: OpenAIAuth = { + type: "oauth", + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + ...(accountId ? { accountId } : {}), + }; + await writeAuth(updated); + return updated; + } + return auth; +} + +export async function getOpenAIAuthStatus() { + const auth = await readAuth(); + const status = auth && oauthState.status === "idle" ? "success" : oauthState.status; + return { + ...oauthState, + status, + connected: Boolean(auth), + accountId: auth?.accountId, + }; +} + +const HTML_SUCCESS = ` + + + Stackfish - Codex Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to Stackfish.

+
+ + +`; + +const HTML_ERROR = (error: string) => ` + + + Stackfish - Codex Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${error}
+
+ +`; + +function clearPending() { + if (pending) { + clearTimeout(pending.timeout); + } + pending = undefined; +} + +function stopOAuthServer() { + if (!server) return; + server.close(); + server = undefined; +} + +async function handleCallback(url: URL, res: http.ServerResponse) { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const message = errorDescription || error; + oauthState = { status: "error", error: message }; + clearPending(); + stopOAuthServer(); + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(HTML_ERROR(message)); + return; + } + + if (!code) { + const message = "Missing authorization code"; + oauthState = { status: "error", error: message }; + clearPending(); + stopOAuthServer(); + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(HTML_ERROR(message)); + return; + } + + if (!pending || state !== pending.state) { + const message = "Invalid state - potential CSRF attack"; + oauthState = { status: "error", error: message }; + clearPending(); + stopOAuthServer(); + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(HTML_ERROR(message)); + return; + } + + try { + const tokens = await exchangeCodeForTokens(code, `http://localhost:${OPENAI_OAUTH_PORT}/auth/callback`, pending.pkce); + const accountId = extractAccountId(tokens); + const auth: OpenAIAuth = { + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + ...(accountId ? { accountId } : {}), + }; + await writeAuth(auth); + oauthState = { status: "success" }; + clearPending(); + stopOAuthServer(); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(HTML_SUCCESS); + } catch (err) { + const message = err instanceof Error ? err.message : "Token exchange failed"; + oauthState = { status: "error", error: message }; + clearPending(); + stopOAuthServer(); + res.writeHead(500, { "Content-Type": "text/html" }); + res.end(HTML_ERROR(message)); + } +} + +function handleCancel(res: http.ServerResponse) { + oauthState = { status: "error", error: "Login cancelled" }; + clearPending(); + stopOAuthServer(); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Login cancelled"); +} + +async function startOAuthServer() { + if (server) return; + server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + return; + } + const url = new URL(req.url, `http://localhost:${OPENAI_OAUTH_PORT}`); + if (url.pathname === "/auth/callback") { + void handleCallback(url, res); + return; + } + if (url.pathname === "/cancel") { + handleCancel(res); + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + }); + + await new Promise((resolve, reject) => { + server!.once("error", reject); + server!.listen(OPENAI_OAUTH_PORT, () => resolve()); + }); +} + +export async function startOpenAIAuthorize() { + await startOAuthServer(); + const pkce = await generatePKCE(); + const state = generateState(); + clearPending(); + pending = { + pkce, + state, + timeout: setTimeout(() => { + oauthState = { status: "error", error: "OAuth callback timeout - authorization took too long" }; + clearPending(); + stopOAuthServer(); + }, OAUTH_TIMEOUT_MS), + }; + oauthState = { status: "pending" }; + return { + url: buildAuthorizeUrl(`http://localhost:${OPENAI_OAUTH_PORT}/auth/callback`, pkce, state), + method: "auto" as const, + instructions: "Complete authorization in your browser. This window will close automatically.", + }; +} diff --git a/www/app/services/problemService.ts b/www/app/services/problemService.ts index 1c96eef..8c55066 100644 --- a/www/app/services/problemService.ts +++ b/www/app/services/problemService.ts @@ -135,7 +135,15 @@ export class ProblemService { }; const response = await fetch(url, options); - return response.json(); + const body = await response.text(); + if (!body) { + return { success: false, error: `Empty response (${response.status})` } as T; + } + try { + return JSON.parse(body) as T; + } catch { + return { success: false, error: body } as T; + } } catch (error) { console.error('Error in fetchApi:', error); return {success: false, error: error} as T; @@ -626,4 +634,4 @@ export class ProblemService { }); } -} \ No newline at end of file +} diff --git a/www/app/types/models.ts b/www/app/types/models.ts index 3a4c8f3..c3c3ba2 100644 --- a/www/app/types/models.ts +++ b/www/app/types/models.ts @@ -1,6 +1,9 @@ -export type Model = 'o1-preview' | - 'o1-mini' | - 'gpt-4o' | - 'gpt-4o-mini' | - 'qwq-32b-preview' | - 'llama-3.3-70b'; \ No newline at end of file +export type Model = + | 'gpt-5.3-codex' + | 'gpt-5.2-codex' + | 'gpt-5.2' + | 'gpt-5.1-codex' + | 'gpt-5.1-codex-mini' + | 'gpt-5.1-codex-max' + | 'qwq-32b-preview' + | 'llama-3.3-70b'; diff --git a/www/config.env.example b/www/config.env.example index 2321020..1947b3e 100644 --- a/www/config.env.example +++ b/www/config.env.example @@ -1,2 +1 @@ -OPENAI_API_KEY= -TOGETHER_API_KEY= \ No newline at end of file +TOGETHER_API_KEY= diff --git a/www/package-lock.json b/www/package-lock.json index fc07b74..d611731 100644 --- a/www/package-lock.json +++ b/www/package-lock.json @@ -12,8 +12,8 @@ "axios": "^1.7.8", "dotenv": "^16.4.5", "jest": "^29.7.0", - "next": "15.0.3", - "openai": "^4.73.1", + "next": "^15.0.3", + "openai": "^4.89.0", "p-queue": "^8.0.1", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", @@ -6598,6 +6598,7 @@ "version": "15.0.3", "resolved": "https://registry.npmjs.org/next/-/next-15.0.3.tgz", "integrity": "sha512-ontCbCRKJUIoivAdGB34yCaOcPgYXr9AAkV/IwqFfWWTXEPUgLYkSkqBhIk9KK7gGmgjc64B+RdoeIDM13Irnw==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", "license": "MIT", "dependencies": { "@next/env": "15.0.3", @@ -6906,9 +6907,9 @@ } }, "node_modules/openai": { - "version": "4.73.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.73.1.tgz", - "integrity": "sha512-nWImDJBcUsqrhy7yJScXB4+iqjzbUEgzfA3un/6UnHFdwWhjX24oztj69Ped/njABfOdLcO/F7CeWTI5dt8Xmg==", + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", "license": "Apache-2.0", "dependencies": { "@types/node": "^18.11.18", @@ -6923,9 +6924,13 @@ "openai": "bin/cli" }, "peerDependencies": { + "ws": "^8.18.0", "zod": "^3.23.8" }, "peerDependenciesMeta": { + "ws": { + "optional": true + }, "zod": { "optional": true } diff --git a/www/package.json b/www/package.json index cf2fd42..0f344cc 100644 --- a/www/package.json +++ b/www/package.json @@ -14,8 +14,8 @@ "axios": "^1.7.8", "dotenv": "^16.4.5", "jest": "^29.7.0", - "next": "15.0.3", - "openai": "^4.73.1", + "next": "^15.0.3", + "openai": "^4.89.0", "p-queue": "^8.0.1", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106",