feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode

This commit is contained in:
William Valentin
2026-02-13 14:55:40 -08:00
parent 8f644d5e25
commit 955b9e28e0
50 changed files with 5955 additions and 160 deletions
+151 -6
View File
@@ -1,11 +1,16 @@
import OpenAI from 'openai';
import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart } from './types.js';
import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart, TokenUsage } from './types.js';
import { getMessageTextWithTools } from './media.js';
import { ensureValidOpenAIAuth } from '../auth/openai.js';
export interface OpenAIClientConfig {
apiKey?: string;
model: string;
maxTokens?: number;
baseURL?: string;
timeoutMs?: number;
/** If true, use ChatGPT subscription OAuth via the Codex backend endpoint. */
useOAuth?: boolean;
}
/**
@@ -52,20 +57,160 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA
}
export class OpenAIClient implements ModelClient {
private client: OpenAI;
private client?: OpenAI;
private model: string;
private defaultMaxTokens: number;
private useOAuth: boolean;
constructor(config: OpenAIClientConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
const timeoutMs = config.timeoutMs ?? 20_000;
this.useOAuth = Boolean(config.useOAuth);
// OAuth mode uses a different backend (ChatGPT Codex) and a different API shape.
// Only initialize the OpenAI SDK for API-key providers.
if (!this.useOAuth) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
timeout: timeoutMs,
maxRetries: 0,
});
}
this.model = config.model;
this.defaultMaxTokens = config.maxTokens ?? 4096;
}
private async chatViaOAuthCodex(request: ChatRequest): Promise<ChatResponse> {
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
const auth = await ensureValidOpenAIAuth();
// Codex endpoint requires:
// - instructions (non-empty)
// - input must be a list
// - store must be false
// - stream must be true (SSE)
const instructions = (request.system ?? '').trim() || 'You are helpful.';
const input = request.messages
.map((m) => {
const text = getMessageTextWithTools(m);
if (!text) {return null;}
return {
role: m.role,
content: [{ type: 'input_text', text }],
};
})
.filter((x): x is NonNullable<typeof x> => Boolean(x));
const body = {
model: this.model,
instructions,
store: false,
stream: true,
input,
// Intentionally omit max_output_tokens: Codex endpoint rejects it.
// Also omit tools/tool_choice for now.
};
const headers: Record<string, string> = {
'content-type': 'application/json',
'authorization': `Bearer ${auth.access_token}`,
'originator': 'flynn',
'user-agent': 'flynn/0.1',
'session_id': `flynn-${Date.now()}`,
};
if (auth.account_id) {
headers['ChatGPT-Account-Id'] = auth.account_id;
}
const res = await fetch(CODEX_API_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`);
}
if (!res.body) {
throw new Error('OpenAI OAuth request failed: missing response body');
}
let buffer = '';
let outputText = '';
let usage: TokenUsage | undefined;
const reader = res.body.getReader();
const processBlock = (block: string): void => {
const lines = block.split('\n');
let data = '';
for (const line of lines) {
if (line.startsWith('data:')) {
data += line.slice('data:'.length).trim();
}
}
if (!data) {return;}
let obj: any;
try {
obj = JSON.parse(data);
} catch {
return;
}
if (obj.type === 'response.output_text.delta' && typeof obj.delta === 'string') {
outputText += obj.delta;
}
if (obj.type === 'response.completed') {
const u = obj.response?.usage;
if (u) {
usage = {
inputTokens: u.input_tokens ?? 0,
outputTokens: u.output_tokens ?? 0,
};
}
}
if (obj.type === 'response.failed') {
const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed';
throw new Error(detail);
}
};
while (true) {
const { value, done } = await reader.read();
if (done) {break;}
buffer += Buffer.from(value).toString('utf8');
while (true) {
const idx = buffer.indexOf('\n\n');
if (idx === -1) {break;}
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
processBlock(block);
}
}
return {
content: outputText,
stopReason: 'end_turn',
usage: usage ?? { inputTokens: 0, outputTokens: 0 },
};
}
async chat(request: ChatRequest): Promise<ChatResponse> {
if (this.useOAuth) {
return this.chatViaOAuthCodex(request);
}
if (!this.client) {
throw new Error('OpenAI client not initialized');
}
const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) {