mirror of
https://github.com/fbarrca/obsidian-inlineAI.git
synced 2026-07-22 11:50:24 +00:00
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:
parent
3912da8047
commit
6880043c8b
4 changed files with 427 additions and 5 deletions
44
src/api.ts
44
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<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
201
src/codex-auth.ts
Normal 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
127
src/codex-client.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue