feat: add Codex (ChatGPT subscription) provider

OAuth PKCE flow to auth w/ ChatGPT Plus/Pro; stores tokens in plugin
settings. Calls chatgpt.com/backend-api/codex/responses (responses API)
instead of chat completions. Auto-refreshes tokens. Sign in/out UI in
settings tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felix Isaac Lim 2026-05-23 05:33:00 +08:00
parent 3912da8047
commit 6880043c8b
4 changed files with 427 additions and 5 deletions

View file

@ -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<string> {
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<string> {
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.

201
src/codex-auth.ts Normal file
View file

@ -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<string, any> | 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<CodexTokens | null> {
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<CodexTokens | null> {
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<void>,
): Promise<string | null> {
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<CodexTokens | null> {
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("<html><body><h2>Signed in! You can close this tab.</h2></body></html>");
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);
});
}

127
src/codex-client.ts Normal file
View file

@ -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<string> {
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;
}

View file

@ -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")