Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 additions & 0 deletions deeptutor/services/llm/codex_provider.py

Large diffs are not rendered by default.

31 changes: 24 additions & 7 deletions deeptutor/services/llm/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,16 @@ async def _do_complete(
) -> str:
try:
if provider_mode == "oauth" and provider_name == "openai_codex":
raise LLMConfigError(
"openai_codex requires OAuth login in CLI. "
"Run `deeptutor provider login openai-codex` first."
from . import codex_provider

return await codex_provider.complete(
prompt=prompt_value,
system_prompt=system_prompt_value,
model=model_value,
api_key=api_key_value,
messages=messages_value,
reasoning_effort=reasoning_effort,
**extra_kwargs,
)
if provider_mode == "oauth":
raise LLMConfigError(
Expand Down Expand Up @@ -387,10 +394,20 @@ async def stream(
for attempt in range(total_attempts):
try:
if provider_mode == "oauth" and provider_name == "openai_codex":
raise LLMConfigError(
"openai_codex requires OAuth login in CLI. "
"Run `deeptutor provider login openai-codex` first."
)
from . import codex_provider

async for chunk in codex_provider.stream(
prompt=prompt,
system_prompt=system_prompt,
model=model,
api_key=api_key,
messages=messages,
reasoning_effort=reasoning_effort,
**extra_kwargs,
):
has_yielded = True
yield chunk
return
if provider_mode == "oauth":
raise LLMConfigError(
f"{provider_name} requires OAuth session. "
Expand Down
20 changes: 19 additions & 1 deletion web/app/(utility)/memory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Brain, Eraser, Loader2, RefreshCw, Save, BookOpen, User } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAppShell } from "@/context/AppShellContext";
import { apiUrl } from "@/lib/api";
import { API_BASE_URL, apiUrl } from "@/lib/api";

const MarkdownRenderer = dynamic(() => import("@/components/common/MarkdownRenderer"), {
ssr: false,
Expand Down Expand Up @@ -63,6 +63,7 @@ export default function MemoryPage() {
const [activeView, setActiveView] = useState<"edit" | "preview">("edit");
const [editors, setEditors] = useState<Record<MemoryFile, string>>({ summary: "", profile: "" });
const [toast, setToast] = useState("");
const [loadError, setLoadError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const tab = TABS.find((t) => t.key === activeTab)!;
Expand All @@ -79,10 +80,21 @@ export default function MemoryPage() {
const loadMemory = useCallback(async () => {
setLoading(true);
try {
setLoadError(null);
const res = await fetch(apiUrl("/api/v1/memory"));
if (!res.ok) {
throw new Error(`Memory request failed with ${res.status}`);
}
const d: MemoryData = await res.json();
setData(d);
setEditors({ summary: d.summary || "", profile: d.profile || "" });
} catch (error) {
const message =
error instanceof Error
? error.message
: `Could not reach backend at ${API_BASE_URL}`;
setLoadError(message);
setToast(`Backend unavailable. Start the API server on ${API_BASE_URL}.`);
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -155,6 +167,12 @@ export default function MemoryPage() {
return (
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
<div className="mx-auto max-w-[960px] px-6 py-8">
{loadError ? (
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/8 px-4 py-3 text-[13px] text-red-500">
Could not reach the backend at <code className="rounded bg-black/10 px-1 py-0.5">{API_BASE_URL}</code>.
Start <code className="rounded bg-black/10 px-1 py-0.5">python -m deeptutor.api.run_server</code> and refresh.
</div>
) : null}

{/* Header */}
<div className="mb-6 flex items-start justify-between">
Expand Down
58 changes: 46 additions & 12 deletions web/app/(utility)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ import {
import { useTranslation } from "react-i18next";

import { writeStoredLanguage } from "@/context/AppShellContext";
import { apiUrl } from "@/lib/api";
import { API_BASE_URL, apiUrl } from "@/lib/api";
import { setTheme as applyThemePreference } from "@/lib/theme";
import { CodexSection } from "@/components/CodexSection";

type ServiceName = "llm" | "embedding" | "search";

Expand Down Expand Up @@ -378,6 +379,7 @@ function SettingsPageContent() {
const [applying, setApplying] = useState(false);
const [toast, setToast] = useState<string>("");
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [providers, setProviders] = useState<Record<ServiceName, ProviderOption[]>>({ llm: [], embedding: [], search: [] });
const eventSourceRef = useRef<EventSource | null>(null);

Expand All @@ -393,19 +395,42 @@ function SettingsPageContent() {

useEffect(() => {
const load = async () => {
const settingsResponse = await fetch(apiUrl("/api/v1/settings"));
const settingsPayload = (await settingsResponse.json()) as SettingsPayload;
setCatalog(settingsPayload.catalog);
setDraft(cloneCatalog(settingsPayload.catalog));
setTheme(settingsPayload.ui.theme);
setLanguage(settingsPayload.ui.language);
if (settingsPayload.providers) setProviders(settingsPayload.providers);
try {
setLoadError(null);

const statusResponse = await fetch(apiUrl("/api/v1/system/status"));
const statusPayload = (await statusResponse.json()) as SystemStatus;
setStatus(statusPayload);
const settingsResponse = await fetch(apiUrl("/api/v1/settings"));
if (!settingsResponse.ok) {
throw new Error(`Settings request failed with ${settingsResponse.status}`);
}
const settingsPayload = (await settingsResponse.json()) as SettingsPayload;
setCatalog(settingsPayload.catalog);
setDraft(cloneCatalog(settingsPayload.catalog));
setTheme(settingsPayload.ui.theme);
setLanguage(settingsPayload.ui.language);
if (settingsPayload.providers) setProviders(settingsPayload.providers);

const statusResponse = await fetch(apiUrl("/api/v1/system/status"));
if (!statusResponse.ok) {
throw new Error(`Status request failed with ${statusResponse.status}`);
}
const statusPayload = (await statusResponse.json()) as SystemStatus;
setStatus(statusPayload);
} catch (error) {
const message =
error instanceof Error
? error.message
: `Could not reach backend at ${API_BASE_URL}`;
setLoadError(message);
setToast(`Backend unavailable. Start the API server on ${API_BASE_URL}.`);
setStatus({
backend: { status: "offline", timestamp: new Date().toISOString() },
llm: { status: "unknown", error: message },
embeddings: { status: "unknown", error: message },
search: { status: "unknown", error: message },
});
}
};
load();
void load();
return () => {
if (eventSourceRef.current) eventSourceRef.current.close();
};
Expand Down Expand Up @@ -775,6 +800,12 @@ function SettingsPageContent() {
return (
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
<div className="mx-auto max-w-[960px] px-6 py-8">
{loadError ? (
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/8 px-4 py-3 text-[13px] text-red-500">
Could not reach the backend at <code className="rounded bg-black/10 px-1 py-0.5">{API_BASE_URL}</code>.
Start <code className="rounded bg-black/10 px-1 py-0.5">python -m deeptutor.api.run_server</code> and refresh.
</div>
) : null}

{/* ── Tour Banner ── */}
{isTourMode && !tourCompleted && (
Expand Down Expand Up @@ -1224,6 +1255,9 @@ function SettingsPageContent() {
)}
</div>

{/* ── Codex Session ── */}
<CodexSection />

{/* ── Footer ── */}
<div className="flex items-center justify-between border-t border-[var(--border)]/30 pt-4 pb-2">
{!isTourMode && (
Expand Down
195 changes: 195 additions & 0 deletions web/app/api/codex/apply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
export const runtime = 'nodejs';

import { NextRequest, NextResponse } from 'next/server';

const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';

/**
* POST /api/codex/apply
*
* Accepts { apiKey, model } and writes an openai_codex binding profile
* into the backend catalog, then applies it to .env.
*
* This bridges the frontend-only OAuth credential into the backend LLM pipeline.
*/
export async function POST(req: NextRequest) {
try {
const { apiKey, model } = (await req.json()) as {
apiKey?: string;
model?: string;
};

if (!apiKey?.trim()) {
return NextResponse.json({ error: 'apiKey is required' }, { status: 400 });
}

const effectiveModel = model?.trim() || 'gpt-5.3-codex';

// 1. Fetch current settings from backend
const settingsRes = await fetch(`${BACKEND_URL}/api/v1/settings`);
if (!settingsRes.ok) {
return NextResponse.json(
{ error: 'Could not read current settings from backend' },
{ status: 502 },
);
}
const settings = (await settingsRes.json()) as {
catalog: {
version: number;
services: {
llm: {
active_profile_id: string | null;
active_model_id?: string | null;
profiles: Array<{
id: string;
name: string;
binding?: string;
base_url: string;
api_key: string;
api_version: string;
models: Array<{ id: string; name: string; model: string }>;
}>;
};
embedding: unknown;
search: unknown;
};
};
};

const catalog = settings.catalog;
const llmService = catalog.services.llm;

// 2. Find or create the Codex profile
const CODEX_PROFILE_ID = 'codex-session';
const CODEX_MODEL_ID = 'codex-model';

let codexProfile = llmService.profiles.find((p) => p.id === CODEX_PROFILE_ID);

if (codexProfile) {
// Update existing
codexProfile.api_key = apiKey;
codexProfile.binding = 'openai_codex';
codexProfile.base_url = 'https://chatgpt.com/backend-api';
const existingModel = codexProfile.models.find((m) => m.id === CODEX_MODEL_ID);
if (existingModel) {
existingModel.model = effectiveModel;
existingModel.name = effectiveModel;
} else {
codexProfile.models = [
{ id: CODEX_MODEL_ID, name: effectiveModel, model: effectiveModel },
];
}
} else {
// Create new
codexProfile = {
id: CODEX_PROFILE_ID,
name: 'Codex Session',
binding: 'openai_codex',
base_url: 'https://chatgpt.com/backend-api',
api_key: apiKey,
api_version: '',
models: [
{ id: CODEX_MODEL_ID, name: effectiveModel, model: effectiveModel },
],
};
llmService.profiles.push(codexProfile);
}

// 3. Set as active
llmService.active_profile_id = CODEX_PROFILE_ID;
llmService.active_model_id = CODEX_MODEL_ID;

// 4. Apply to backend (.env + runtime)
const applyRes = await fetch(`${BACKEND_URL}/api/v1/settings/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalog }),
});

if (!applyRes.ok) {
const errorText = await applyRes.text();
return NextResponse.json(
{ error: `Backend apply failed: ${errorText}` },
{ status: 502 },
);
}

const applied = await applyRes.json();
return NextResponse.json({
ok: true,
catalog: applied.catalog,
model: effectiveModel,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not apply Codex config';
return NextResponse.json({ error: message }, { status: 500 });
}
}

/**
* DELETE /api/codex/apply
*
* Remove the Codex profile and fall back to the first available profile.
*/
export async function DELETE() {
try {
const settingsRes = await fetch(`${BACKEND_URL}/api/v1/settings`);
if (!settingsRes.ok) {
return NextResponse.json(
{ error: 'Could not read current settings' },
{ status: 502 },
);
}
const settings = (await settingsRes.json()) as {
catalog: {
version: number;
services: {
llm: {
active_profile_id: string | null;
active_model_id?: string | null;
profiles: Array<{
id: string;
models: Array<{ id: string }>;
}>;
};
embedding: unknown;
search: unknown;
};
};
};

const catalog = settings.catalog;
const llmService = catalog.services.llm;

// Remove the codex profile
llmService.profiles = llmService.profiles.filter(
(p) => p.id !== 'codex-session',
);

// Fall back to first remaining profile
if (llmService.active_profile_id === 'codex-session') {
const fallback = llmService.profiles[0];
llmService.active_profile_id = fallback?.id ?? null;
llmService.active_model_id = fallback?.models?.[0]?.id ?? null;
}

const applyRes = await fetch(`${BACKEND_URL}/api/v1/settings/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalog }),
});

if (!applyRes.ok) {
const errorText = await applyRes.text();
return NextResponse.json(
{ error: `Backend apply failed: ${errorText}` },
{ status: 502 },
);
}

return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not remove Codex config';
return NextResponse.json({ error: message }, { status: 500 });
}
}
Loading
Loading