diff --git a/src/api.ts b/src/api.ts index c4b643d..de10b20 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,6 +10,8 @@ import { import { InlineAISettings } from "./settings"; import { App, MarkdownView, Notice } from "obsidian"; import { EditorView } from "@codemirror/view"; +import { callCodexApi } from "./codex-client"; +import { getValidCodexToken, CodexTokens } from "./codex-auth"; import { setGeneratedResponseEffect } from "./modules/AIExtension"; import { parseCommand } from "./modules/commands/parser"; import { MessageQueue } from "./modules/messageHistory/queue"; @@ -163,6 +165,10 @@ export class ChatApiManager { }, }); + case "codex": + // Handled directly in callApi — no LangChain client needed + return null; + default: new Notice(`⚠️ Unsupported provider: ${settings.provider}`); return null; @@ -184,6 +190,10 @@ export class ChatApiManager { systemMessage: string, message: string, ): Promise { + if (this.settings.provider === "codex") { + return this.callCodexProvider(systemMessage, message); + } + if (!this.chatClient) { new Notice( "⚠️ Chat client is not initialized. Please check your settings.", @@ -209,6 +219,40 @@ export class ChatApiManager { } } + private async callCodexProvider(systemMessage: string, message: string): Promise { + const s = this.settings; + if (!s.codexAccess || !s.codexRefresh || !s.codexAccountId) { + new Notice("⚠️ Codex: not signed in — open Settings → InlineAI and click 'Sign in with ChatGPT'"); + return "⚠️ Codex not authenticated."; + } + + try { + const tokens: CodexTokens = { + access: s.codexAccess, + refresh: s.codexRefresh, + expires: s.codexExpires ?? 0, + accountId: s.codexAccountId, + }; + + const accessToken = await getValidCodexToken(tokens, async (refreshed) => { + this.settings.codexAccess = refreshed.access; + this.settings.codexRefresh = refreshed.refresh; + this.settings.codexExpires = refreshed.expires; + }); + + if (!accessToken) { + new Notice("⚠️ Codex: session expired — please sign in again"); + return "⚠️ Codex session expired."; + } + + return await callCodexApi(systemMessage, message, accessToken, s.codexAccountId, s.model); + } catch (error: any) { + console.error("Codex error:", error); + new Notice(`❌ Codex: ${error.message}`); + return "⚠️ Codex request failed."; + } + } + /** * Handles user input and updates the editor with the response. * @param systemPrompt - The system prompt to send to the chat API. diff --git a/src/codex-auth.ts b/src/codex-auth.ts new file mode 100644 index 0000000..be18945 --- /dev/null +++ b/src/codex-auth.ts @@ -0,0 +1,201 @@ +import * as http from "http"; +import { Notice } from "obsidian"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const SCOPE = "openid profile email offline_access"; +const CALLBACK_PORT = 1455; + +export interface CodexTokens { + access: string; + refresh: string; + expires: number; + accountId: string; +} + +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const verifier = btoa(String.fromCharCode(...array)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + return { verifier, challenge }; +} + +function randomState(): string { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function decodeJWT(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + return JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))); + } catch { + return null; + } +} + +function extractAccountId(accessToken: string): string | null { + const payload = decodeJWT(accessToken); + if (!payload) return null; + const auth = payload["https://api.openai.com/auth"]; + return auth?.user_id ?? auth?.account_id ?? null; +} + +async function exchangeCode(code: string, verifier: string): Promise { + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }), + }); + + if (!res.ok) return null; + + const json = await res.json() as any; + if (!json.access_token || !json.refresh_token) return null; + + const accountId = extractAccountId(json.access_token); + if (!accountId) return null; + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + accountId, + }; +} + +export async function refreshCodexToken(tokens: CodexTokens): Promise { + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh, + client_id: CLIENT_ID, + }), + }); + + if (!res.ok) return null; + + const json = await res.json() as any; + if (!json.access_token || !json.refresh_token) return null; + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + accountId: tokens.accountId, + }; +} + +export async function getValidCodexToken( + tokens: CodexTokens, + onRefresh: (t: CodexTokens) => Promise, +): Promise { + if (tokens.expires > Date.now() + 60_000) return tokens.access; + + const refreshed = await refreshCodexToken(tokens); + if (!refreshed) return null; + + await onRefresh(refreshed); + return refreshed.access; +} + +export async function startCodexOAuthFlow(): Promise { + const { verifier, challenge } = await generatePKCE(); + const state = randomState(); + + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "codex_cli_rs"); + + return new Promise((resolve) => { + let resolved = false; + let server: http.Server | null = null; + + const done = (tokens: CodexTokens | null) => { + if (resolved) return; + resolved = true; + server?.close(); + resolve(tokens); + }; + + server = http.createServer(async (req, res) => { + if (!req.url?.startsWith("/auth/callback")) { + res.writeHead(404); + res.end(); + return; + } + + const params = new URL(req.url, "http://localhost").searchParams; + const code = params.get("code"); + const returnedState = params.get("state"); + + if (!code || returnedState !== state) { + res.writeHead(400); + res.end("Invalid callback"); + done(null); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("

Signed in! You can close this tab.

"); + + const tokens = await exchangeCode(code, verifier); + if (!tokens) { + new Notice("❌ Codex: failed to exchange auth code for tokens"); + } + done(tokens); + }); + + server.on("error", (e: any) => { + if (e.code === "EADDRINUSE") { + new Notice("❌ Codex: port 1455 in use — close other Codex sessions first"); + } + done(null); + }); + + server.listen(CALLBACK_PORT, "127.0.0.1", () => { + window.open(url.toString()); + new Notice("🔐 Codex: browser opened — complete sign-in to continue"); + }); + + // Timeout after 5 minutes + setTimeout(() => { + if (!resolved) { + new Notice("⚠️ Codex: sign-in timed out"); + done(null); + } + }, 5 * 60 * 1000); + }); +} diff --git a/src/codex-client.ts b/src/codex-client.ts new file mode 100644 index 0000000..94e830e --- /dev/null +++ b/src/codex-client.ts @@ -0,0 +1,127 @@ +const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses"; + +interface ResponsesInput { + type: "message"; + role: "developer" | "user" | "assistant"; + content: Array<{ type: "input_text"; text: string }>; +} + +interface ResponsesBody { + model: string; + input: ResponsesInput[]; + instructions: string; + store: false; + stream: true; + reasoning: { effort: string; summary: string }; + text: { verbosity: string }; + include: string[]; +} + +function normalizeModel(model: string): string { + const m = model.toLowerCase().trim(); + if (m.includes("gpt-5.2-codex") || m.includes("gpt 5.2 codex")) return "gpt-5.2-codex"; + if (m.includes("gpt-5.1-codex-max") || m.includes("codex-max")) return "gpt-5.1-codex-max"; + if (m.includes("codex-mini-latest") || m.includes("codex-mini")) return "codex-mini-latest"; + if (m.includes("gpt-5.1-codex") || m.includes("codex")) return "gpt-5.1-codex"; + if (m.includes("gpt-5.2")) return "gpt-5.2"; + if (m.includes("gpt-5.1")) return "gpt-5.1"; + return "gpt-5.1-codex"; +} + +function parseSseText(sseBody: string): string { + const lines = sseBody.split("\n"); + const parts: string[] = []; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") break; + + try { + const json = JSON.parse(data) as any; + // Responses API emits output[].content[].text deltas + for (const output of json.output ?? []) { + for (const content of output.content ?? []) { + if (content.type === "output_text" && content.text) { + parts.push(content.text); + } + } + } + // Delta format + const delta = json.delta; + if (delta?.type === "output_text" && delta.text) { + parts.push(delta.text); + } + // Snapshot format (non-streaming final) + if (json.type === "response.completed") { + const output = json.response?.output ?? []; + for (const item of output) { + for (const c of item.content ?? []) { + if (c.type === "output_text" && c.text) { + parts.push(c.text); + } + } + } + } + } catch { + // ignore malformed lines + } + } + + return parts.join(""); +} + +export async function callCodexApi( + systemMessage: string, + userMessage: string, + accessToken: string, + accountId: string, + model: string, +): Promise { + const normalizedModel = normalizeModel(model); + + const body: ResponsesBody = { + model: normalizedModel, + input: [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: systemMessage }], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }], + }, + ], + instructions: "", + store: false, + stream: true, + reasoning: { effort: "medium", summary: "auto" }, + text: { verbosity: "medium" }, + include: ["reasoning.encrypted_content"], + }; + + const res = await fetch(CODEX_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "chatgpt-account-id": accountId, + "OpenAI-Beta": "responses=experimental", + "originator": "codex_cli_rs", + "accept": "text/event-stream", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Codex API ${res.status}: ${text.slice(0, 200)}`); + } + + const rawText = await res.text(); + const result = parseSseText(rawText); + if (!result) throw new Error("Codex returned empty response"); + return result; +} diff --git a/src/settings.ts b/src/settings.ts index 6f32431..06f7beb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,11 +1,12 @@ -import { App, PluginSettingTab, Setting } from "obsidian"; +import { App, PluginSettingTab, Setting, Notice } from "obsidian"; import MyPlugin from "./main"; import { cursorPrompt, selectionPrompt } from "./default_prompts"; import { SlashCommand } from "./modules/commands/source"; +import { startCodexOAuthFlow } from "./codex-auth"; // Interface for the settings export interface InlineAISettings { - provider: "openai" | "ollama" | "custom" | "gemini" | "azure"; + provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex"; model: string; apiKey?: string; customURL?: string; @@ -16,6 +17,11 @@ export interface InlineAISettings { customCommands: SlashCommand[]; commandPrefix: string; messageHistory: boolean; + // Codex subscription OAuth tokens + codexAccess?: string; + codexRefresh?: string; + codexExpires?: number; + codexAccountId?: string; } // Default settings values @@ -55,7 +61,7 @@ export class InlineAISettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Provider") .setDesc( - "Choose between OpenAI, Ollama, Azure OpenAI, Gemini, or a custom OpenAI-compatible endpoint.", + "Choose between OpenAI, Ollama, Azure OpenAI, Gemini, a custom OpenAI-compatible endpoint, or Codex (ChatGPT subscription).", ) .addDropdown((dropdown) => dropdown @@ -64,6 +70,7 @@ export class InlineAISettingsTab extends PluginSettingTab { .addOption("azure", "Azure OpenAI") .addOption("gemini", "Gemini") .addOption("custom", "Custom/OpenAI-compatible") + .addOption("codex", "Codex (ChatGPT subscription)") .setValue(this.plugin.settings.provider) .onChange(async (value) => { this.plugin.settings.provider = value as @@ -71,12 +78,55 @@ export class InlineAISettingsTab extends PluginSettingTab { | "ollama" | "azure" | "custom" - | "gemini"; + | "gemini" + | "codex"; await this.saveSettings(); - this.display(); // Refresh UI to show/hide API key field + this.display(); }), ); + // Codex subscription auth section + if (this.plugin.settings.provider === "codex") { + const isSignedIn = !!( + this.plugin.settings.codexAccess && + this.plugin.settings.codexAccountId + ); + + new Setting(containerEl) + .setName("ChatGPT account") + .setDesc( + isSignedIn + ? `Signed in (account: ${this.plugin.settings.codexAccountId})` + : "Not signed in — click to authenticate with your ChatGPT Plus/Pro subscription.", + ) + .addButton((btn) => { + btn.setButtonText(isSignedIn ? "Sign out" : "Sign in with ChatGPT") + .setCta() + .onClick(async () => { + if (isSignedIn) { + this.plugin.settings.codexAccess = undefined; + this.plugin.settings.codexRefresh = undefined; + this.plugin.settings.codexExpires = undefined; + this.plugin.settings.codexAccountId = undefined; + await this.saveSettings(); + this.display(); + } else { + new Notice("Opening browser for ChatGPT sign-in…"); + const tokens = await startCodexOAuthFlow(); + if (tokens) { + this.plugin.settings.codexAccess = tokens.access; + this.plugin.settings.codexRefresh = tokens.refresh; + this.plugin.settings.codexExpires = tokens.expires; + this.plugin.settings.codexAccountId = tokens.accountId; + await this.saveSettings(); + new Notice("✅ Codex: signed in successfully"); + this.display(); + } + } + }); + }); + } + // Model setting new Setting(containerEl) .setName("Model")