mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(inference): ChatGPT OAuth for OpenAI cloud provider (#1953)
Add Codex-style PKCE OAuth for the openai provider slug, wire bearer lookup and JSON-RPC controllers, and surface sign-in during onboarding API keys step. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+53
@@ -3064,9 +3064,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4352,6 +4354,22 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "motosan-ai-oauth"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"rand 0.9.4",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -4982,6 +5000,7 @@ dependencies = [
|
||||
"log",
|
||||
"mail-parser",
|
||||
"matrix-sdk",
|
||||
"motosan-ai-oauth",
|
||||
"nu-ansi-term 0.46.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-contacts",
|
||||
@@ -6134,6 +6153,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -6147,6 +6167,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
@@ -7420,6 +7441,27 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys 0.8.7",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
@@ -9061,6 +9103,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
@@ -89,6 +89,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f
|
||||
tracing-appender = "0.2"
|
||||
prometheus = { version = "0.14", default-features = false }
|
||||
urlencoding = "2.1"
|
||||
motosan-ai-oauth = { version = "0.2", features = ["codex"] }
|
||||
thiserror = "2.0"
|
||||
ring = "0.17"
|
||||
prost = { version = "0.14", default-features = false }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { setCloudProviderKey } from '../../../services/api/aiSettingsApi';
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import { isTauri } from '../../../utils/tauriCommands/common';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
interface ApiKeysStepProps {
|
||||
@@ -9,17 +12,98 @@ interface ApiKeysStepProps {
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
type OpenAiOAuthStatus = { connected: boolean; authMethod?: string | null };
|
||||
|
||||
const OPENAI_OAUTH_CONNECTED_LABEL = 'Connected with ChatGPT';
|
||||
const OPENAI_OAUTH_CONNECT_LABEL = 'Sign in with ChatGPT';
|
||||
const OPENAI_OAUTH_CALLBACK_HINT =
|
||||
'After signing in, paste the full redirect URL from your browser (starts with http://127.0.0.1:1455/).';
|
||||
const OPENAI_OAUTH_CALLBACK_PLACEHOLDER = 'http://127.0.0.1:1455/auth/callback?code=...&state=...';
|
||||
|
||||
const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
|
||||
const { t } = useT();
|
||||
const [openai, setOpenai] = useState('');
|
||||
const [anthropic, setAnthropic] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [oauthConnected, setOauthConnected] = useState(false);
|
||||
const [oauthBusy, setOauthBusy] = useState(false);
|
||||
const [oauthAwaitingCallback, setOauthAwaitingCallback] = useState(false);
|
||||
const [oauthCallbackUrl, setOauthCallbackUrl] = useState('');
|
||||
|
||||
const refreshOAuthStatus = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await callCoreRpc<{ result: OpenAiOAuthStatus }>({
|
||||
method: 'openhuman.inference_openai_oauth_status',
|
||||
params: {},
|
||||
});
|
||||
setOauthConnected(Boolean(res?.result?.connected));
|
||||
} catch (err) {
|
||||
console.debug('[onboarding:api-keys] oauth status check failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshOAuthStatus();
|
||||
}, [refreshOAuthStatus]);
|
||||
|
||||
const handleOpenAiOAuthStart = async () => {
|
||||
if (!isTauri()) {
|
||||
setError('ChatGPT sign-in is only available in the desktop app.');
|
||||
return;
|
||||
}
|
||||
setOauthBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await callCoreRpc<{ result: { authUrl: string } }>({
|
||||
method: 'openhuman.inference_openai_oauth_start',
|
||||
params: {},
|
||||
});
|
||||
const authUrl = res?.result?.authUrl?.trim();
|
||||
if (!authUrl) {
|
||||
throw new Error('missing authUrl');
|
||||
}
|
||||
setOauthAwaitingCallback(true);
|
||||
await openUrl(authUrl);
|
||||
} catch (err) {
|
||||
console.warn('[onboarding:api-keys] oauth start failed', err);
|
||||
setError('Could not start ChatGPT sign-in. Try again or use an API key.');
|
||||
} finally {
|
||||
setOauthBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAiOAuthComplete = async () => {
|
||||
const callback = oauthCallbackUrl.trim();
|
||||
if (!callback) {
|
||||
setError('Paste the redirect URL from your browser after signing in.');
|
||||
return;
|
||||
}
|
||||
setOauthBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.inference_openai_oauth_complete',
|
||||
params: { callback_url: callback },
|
||||
});
|
||||
setOauthCallbackUrl('');
|
||||
setOauthAwaitingCallback(false);
|
||||
setOauthConnected(true);
|
||||
} catch (err) {
|
||||
console.warn('[onboarding:api-keys] oauth complete failed', err);
|
||||
setError('ChatGPT sign-in did not complete. Check the redirect URL and try again.');
|
||||
} finally {
|
||||
setOauthBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedOpenai = openai.trim();
|
||||
const trimmedAnthropic = anthropic.trim();
|
||||
if (!trimmedOpenai && !trimmedAnthropic) {
|
||||
if (!trimmedOpenai && !trimmedAnthropic && !oauthConnected) {
|
||||
onSkip();
|
||||
return;
|
||||
}
|
||||
@@ -56,12 +140,65 @@ const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="onboarding-openai-key"
|
||||
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
{t('onboarding.apiKeys.openaiLabel')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/40 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
{t('onboarding.apiKeys.openaiLabel')}
|
||||
</span>
|
||||
{oauthConnected ? (
|
||||
<span
|
||||
data-testid="onboarding-openai-oauth-connected"
|
||||
className="text-xs font-medium text-sage-700 dark:text-sage-300">
|
||||
{OPENAI_OAUTH_CONNECTED_LABEL}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
Use ChatGPT Plus/Pro (subscription) or an OpenAI API key — not both required.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="onboarding-openai-oauth-connect"
|
||||
disabled={oauthBusy || oauthConnected || saving}
|
||||
onClick={() => void handleOpenAiOAuthStart()}
|
||||
className="rounded-lg border border-primary-500 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-500/20 disabled:opacity-50">
|
||||
{oauthBusy ? 'Opening sign-in…' : OPENAI_OAUTH_CONNECT_LABEL}
|
||||
</button>
|
||||
{oauthAwaitingCallback && !oauthConnected ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{OPENAI_OAUTH_CALLBACK_HINT}
|
||||
</p>
|
||||
<input
|
||||
data-testid="onboarding-openai-oauth-callback-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={OPENAI_OAUTH_CALLBACK_PLACEHOLDER}
|
||||
value={oauthCallbackUrl}
|
||||
onChange={e => {
|
||||
setOauthCallbackUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="onboarding-openai-oauth-complete"
|
||||
disabled={oauthBusy || saving}
|
||||
onClick={() => void handleOpenAiOAuthComplete()}
|
||||
className="self-start text-xs font-medium text-primary-600 dark:text-primary-400 underline disabled:opacity-50">
|
||||
Finish ChatGPT sign-in
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative flex items-center gap-2 py-1">
|
||||
<div className="h-px flex-1 bg-stone-200 dark:bg-neutral-700" />
|
||||
<span className="text-[10px] uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
or API key
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-stone-200 dark:bg-neutral-700" />
|
||||
</div>
|
||||
<input
|
||||
id="onboarding-openai-key"
|
||||
data-testid="onboarding-api-keys-openai-input"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import ApiKeysStep from '../ApiKeysStep';
|
||||
|
||||
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
vi.mock('../../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock('../../../../utils/tauriCommands/common', () => ({ isTauri: vi.fn(() => true) }));
|
||||
|
||||
vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
setCloudProviderKey: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('ApiKeysStep OpenAI OAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows connected badge when oauth status reports connected', async () => {
|
||||
const { callCoreRpc } = await import('../../../../services/coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } });
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected with ChatGPT')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts oauth and accepts pasted callback URL', async () => {
|
||||
const { callCoreRpc } = await import('../../../../services/coreRpcClient');
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ result: { connected: false } })
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test',
|
||||
state: 'state-1',
|
||||
redirectUri: 'http://127.0.0.1:1455/auth/callback',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ result: { connected: true } });
|
||||
|
||||
const { openUrl } = await import('../../../../utils/openUrl');
|
||||
|
||||
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openUrl).toHaveBeenCalledWith(
|
||||
'https://auth.openai.com/oauth/authorize?client_id=test'
|
||||
);
|
||||
});
|
||||
|
||||
const input = await screen.findByTestId('onboarding-openai-oauth-callback-input');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.inference_openai_oauth_complete',
|
||||
params: { callback_url: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ pub mod device;
|
||||
pub mod http;
|
||||
pub mod local;
|
||||
pub mod model_ids;
|
||||
pub mod openai_oauth;
|
||||
pub mod ops;
|
||||
pub mod parse;
|
||||
pub mod paths;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! OpenAI Codex (ChatGPT subscription) OAuth endpoints and client registration.
|
||||
|
||||
use motosan_ai_oauth::providers::codex::codex;
|
||||
use motosan_ai_oauth::OAuthConfig;
|
||||
|
||||
/// Loopback redirect registered with the Codex public OAuth app.
|
||||
pub const REDIRECT_URI: &str = "http://127.0.0.1:1455/auth/callback";
|
||||
|
||||
pub fn codex_oauth_config() -> OAuthConfig {
|
||||
codex()
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! OAuth start / complete / status for OpenAI Codex (ChatGPT subscription).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Utc};
|
||||
use motosan_ai_oauth::StateStrategy;
|
||||
use rand::RngExt as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::state_dir_from_config;
|
||||
|
||||
use super::config::{codex_oauth_config, REDIRECT_URI};
|
||||
use super::store::{persist_openai_oauth_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY};
|
||||
|
||||
const LOG_PREFIX: &str = "[inference][openai-oauth]";
|
||||
const PENDING_FILENAME: &str = "openai-oauth-pending.json";
|
||||
const PENDING_TTL_SECS: u64 = 600;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PendingOAuth {
|
||||
state: String,
|
||||
verifier: String,
|
||||
redirect_uri: String,
|
||||
created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OpenAiOAuthStartResult {
|
||||
pub auth_url: String,
|
||||
pub state: String,
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OpenAiOAuthStatusResult {
|
||||
pub connected: bool,
|
||||
pub profile_id: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub auth_method: Option<String>,
|
||||
}
|
||||
|
||||
fn pending_path(config: &Config) -> PathBuf {
|
||||
state_dir_from_config(config).join(PENDING_FILENAME)
|
||||
}
|
||||
|
||||
fn generate_pkce() -> (String, String) {
|
||||
let mut bytes = [0u8; 64];
|
||||
rand::rng().fill(&mut bytes);
|
||||
let verifier = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let hash = Sha256::digest(verifier.as_bytes());
|
||||
let challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
(verifier, challenge)
|
||||
}
|
||||
|
||||
fn random_state() -> String {
|
||||
let mut state_bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut state_bytes);
|
||||
URL_SAFE_NO_PAD.encode(state_bytes)
|
||||
}
|
||||
|
||||
fn write_pending(config: &Config, pending: &PendingOAuth) -> Result<(), String> {
|
||||
let path = pending_path(config);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(pending).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())?;
|
||||
log::debug!("{LOG_PREFIX} pending session written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_pending(config: &Config) -> Result<Option<PendingOAuth>, String> {
|
||||
let path = pending_path(config);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pending: PendingOAuth = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
let now = unix_now_secs();
|
||||
if now.saturating_sub(pending.created_at) > PENDING_TTL_SECS {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(pending))
|
||||
}
|
||||
|
||||
fn clear_pending(config: &Config) {
|
||||
let path = pending_path(config);
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_openai_oauth(config: &Config) -> Result<OpenAiOAuthStartResult, String> {
|
||||
let oauth_cfg = codex_oauth_config();
|
||||
let (verifier, challenge) = generate_pkce();
|
||||
let state = match oauth_cfg.state_strategy {
|
||||
StateStrategy::Random => random_state(),
|
||||
StateStrategy::EqualsVerifier => verifier.clone(),
|
||||
};
|
||||
|
||||
let pending = PendingOAuth {
|
||||
state: state.clone(),
|
||||
verifier,
|
||||
redirect_uri: REDIRECT_URI.to_string(),
|
||||
created_at: unix_now_secs(),
|
||||
};
|
||||
write_pending(config, &pending)?;
|
||||
|
||||
let auth_url = build_authorize_url(&oauth_cfg, &challenge, &state, REDIRECT_URI);
|
||||
log::info!("{LOG_PREFIX} oauth start state_len={}", state.len());
|
||||
|
||||
Ok(OpenAiOAuthStartResult {
|
||||
auth_url,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_callback_input(input: &str) -> Result<(String, String), String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("callback URL is required".to_string());
|
||||
}
|
||||
|
||||
let query = if let Ok(parsed) = url::Url::parse(trimmed) {
|
||||
parsed.query().unwrap_or("").to_string()
|
||||
} else if trimmed.contains('=') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
return Err("invalid callback URL".to_string());
|
||||
};
|
||||
|
||||
let mut code: Option<String> = None;
|
||||
let mut state: Option<String> = None;
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"code" if !value.is_empty() => code = Some(value.into_owned()),
|
||||
"state" if !value.is_empty() => state = Some(value.into_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let code = code.ok_or_else(|| "callback URL missing code parameter".to_string())?;
|
||||
let state = state.ok_or_else(|| "callback URL missing state parameter".to_string())?;
|
||||
Ok((code, state))
|
||||
}
|
||||
|
||||
pub async fn complete_openai_oauth(
|
||||
config: &Config,
|
||||
callback_input: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let pending = read_pending(config)?
|
||||
.ok_or_else(|| "no pending OAuth session; call openai_oauth_start first".to_string())?;
|
||||
|
||||
let (code, returned_state) = parse_callback_input(callback_input)?;
|
||||
if returned_state != pending.state {
|
||||
clear_pending(config);
|
||||
return Err("OAuth state mismatch — try connecting again".to_string());
|
||||
}
|
||||
|
||||
let oauth_cfg = codex_oauth_config();
|
||||
let token = exchange_authorization_code(
|
||||
&oauth_cfg,
|
||||
&code,
|
||||
&pending.state,
|
||||
&pending.verifier,
|
||||
&pending.redirect_uri,
|
||||
)
|
||||
.await?;
|
||||
|
||||
clear_pending(config);
|
||||
let profile = persist_openai_oauth_token(config, &token)?;
|
||||
log::info!("{LOG_PREFIX} oauth complete profile_id={}", profile.id);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"connected": true,
|
||||
"profileId": profile.id,
|
||||
"provider": OPENAI_PROVIDER_KEY,
|
||||
"authMethod": "oauth",
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn openai_oauth_status(config: &Config) -> Result<OpenAiOAuthStatusResult, String> {
|
||||
use crate::openhuman::credentials::profiles::AuthProfileKind;
|
||||
use crate::openhuman::credentials::AuthService;
|
||||
|
||||
let auth = AuthService::from_config(config);
|
||||
let profile = auth
|
||||
.get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(profile) = profile else {
|
||||
return Ok(OpenAiOAuthStatusResult {
|
||||
connected: false,
|
||||
profile_id: None,
|
||||
expires_at: None,
|
||||
auth_method: None,
|
||||
});
|
||||
};
|
||||
|
||||
if profile.kind != AuthProfileKind::OAuth {
|
||||
return Ok(OpenAiOAuthStatusResult {
|
||||
connected: false,
|
||||
profile_id: Some(profile.id),
|
||||
expires_at: None,
|
||||
auth_method: Some("token".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(OpenAiOAuthStatusResult {
|
||||
connected: true,
|
||||
profile_id: Some(profile.id),
|
||||
expires_at: profile.token_set.as_ref().and_then(|t| t.expires_at),
|
||||
auth_method: Some("oauth".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disconnect_openai_oauth(config: &Config) -> Result<serde_json::Value, String> {
|
||||
use crate::openhuman::credentials::AuthService;
|
||||
|
||||
let auth = AuthService::from_config(config);
|
||||
let removed = auth
|
||||
.remove_profile(OPENAI_PROVIDER_KEY, OPENAI_OAUTH_PROFILE_NAME)
|
||||
.map_err(|e| e.to_string())?;
|
||||
clear_pending(config);
|
||||
Ok(serde_json::json!({ "disconnected": removed }))
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
config: &motosan_ai_oauth::OAuthConfig,
|
||||
challenge: &str,
|
||||
state: &str,
|
||||
redirect_uri: &str,
|
||||
) -> String {
|
||||
let mut url = reqwest::Url::parse(config.auth_url).expect("auth_url must be valid");
|
||||
{
|
||||
let mut q = url.query_pairs_mut();
|
||||
q.append_pair("client_id", config.client_id)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &config.scopes.join(" "))
|
||||
.append_pair("state", state)
|
||||
.append_pair("code_challenge", challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
for (k, v) in config.extra_auth_params {
|
||||
q.append_pair(k, v);
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
async fn exchange_authorization_code(
|
||||
config: &motosan_ai_oauth::OAuthConfig,
|
||||
code: &str,
|
||||
state: &str,
|
||||
verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<motosan_ai_oauth::Token, String> {
|
||||
let mut params = vec![
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("state", state),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", verifier),
|
||||
("client_id", config.client_id),
|
||||
];
|
||||
if let Some(secret) = config.client_secret {
|
||||
params.push(("client_secret", secret));
|
||||
}
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(config.token_url)
|
||||
.header("Accept", "application/json")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RawTokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
id_token: Option<String>,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
let raw: RawTokenResponse = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(motosan_ai_oauth::Token {
|
||||
access_token: raw.access_token,
|
||||
refresh_token: raw.refresh_token.unwrap_or_default(),
|
||||
id_token: raw.id_token,
|
||||
expires_in: raw.expires_in,
|
||||
issued_at: unix_now_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
fn unix_now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::flow::parse_callback_input;
|
||||
use super::{
|
||||
complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth,
|
||||
};
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet};
|
||||
use crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token;
|
||||
use crate::openhuman::inference::openai_oauth::store::{
|
||||
OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_config(tmp: &tempfile::TempDir) -> Config {
|
||||
let mut config = Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_openai_oauth_returns_authorize_url() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
|
||||
let start = start_openai_oauth(&config).unwrap();
|
||||
assert!(start.auth_url.contains("auth.openai.com"));
|
||||
assert!(start.auth_url.contains("code_challenge="));
|
||||
assert_eq!(start.redirect_uri, "http://127.0.0.1:1455/auth/callback");
|
||||
assert!(!start.state.is_empty());
|
||||
assert!(!openai_oauth_status(&config).unwrap().connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_accepts_full_redirect_url() {
|
||||
let url = "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz";
|
||||
let (code, state) = parse_callback_input(url).unwrap();
|
||||
assert_eq!(code, "abc");
|
||||
assert_eq!(state, "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_callback_input_rejects_missing_code() {
|
||||
let err = parse_callback_input("http://127.0.0.1:1455/auth/callback?state=xyz").unwrap_err();
|
||||
assert!(err.contains("code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_openai_oauth_rejects_state_mismatch() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let start = start_openai_oauth(&config).unwrap();
|
||||
let callback = format!(
|
||||
"http://127.0.0.1:1455/auth/callback?code=fake&state=not-{}",
|
||||
start.state
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let err = rt
|
||||
.block_on(complete_openai_oauth(&config, &callback))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("state mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_openai_bearer_token_prefers_api_key_over_oauth() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
|
||||
let oauth_profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "oauth-access".into(),
|
||||
refresh_token: Some("refresh".into()),
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(oauth_profile, true).unwrap();
|
||||
|
||||
let api_profile =
|
||||
AuthProfile::new_token("provider:openai", "default", "sk-api-key".to_string());
|
||||
store.upsert_profile(api_profile, true).unwrap();
|
||||
|
||||
let token = lookup_openai_bearer_token(&config).unwrap();
|
||||
assert_eq!(token.as_deref(), Some("sk-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_openai_oauth_clears_profile() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let store = AuthProfilesStore::new(tmp.path(), false);
|
||||
let profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
TokenSet {
|
||||
access_token: "oauth-access".into(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_at: None,
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
store.upsert_profile(profile, true).unwrap();
|
||||
assert!(openai_oauth_status(&config).unwrap().connected);
|
||||
|
||||
disconnect_openai_oauth(&config).unwrap();
|
||||
assert!(!openai_oauth_status(&config).unwrap().connected);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! ChatGPT / OpenAI Codex subscription OAuth for the `openai` cloud provider slug.
|
||||
|
||||
mod config;
|
||||
mod flow;
|
||||
mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "flow_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub use flow::{
|
||||
complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth,
|
||||
};
|
||||
pub use store::{lookup_openai_bearer_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY};
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Persist and resolve OpenAI OAuth tokens for the `openai` cloud provider slug.
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{Duration, Utc};
|
||||
use motosan_ai_oauth::Token;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet};
|
||||
use crate::openhuman::credentials::{state_dir_from_config, AuthService};
|
||||
use crate::openhuman::inference::provider::factory::auth_key_for_slug;
|
||||
|
||||
use super::config::codex_oauth_config;
|
||||
|
||||
const LOG_PREFIX: &str = "[inference][openai-oauth][store]";
|
||||
|
||||
pub const OPENAI_PROVIDER_KEY: &str = "provider:openai";
|
||||
pub const OPENAI_OAUTH_PROFILE_NAME: &str = "oauth";
|
||||
|
||||
fn token_set_from_codex(token: &Token) -> TokenSet {
|
||||
let expires_at =
|
||||
(token.expires_in > 0).then(|| Utc::now() + Duration::seconds(token.expires_in as i64));
|
||||
TokenSet {
|
||||
access_token: token.access_token.clone(),
|
||||
refresh_token: (!token.refresh_token.is_empty()).then(|| token.refresh_token.clone()),
|
||||
id_token: token.id_token.clone(),
|
||||
expires_at,
|
||||
token_type: Some("Bearer".to_string()),
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn persist_openai_oauth_token(config: &Config, token: &Token) -> Result<AuthProfile, String> {
|
||||
let mut profile = AuthProfile::new_oauth(
|
||||
OPENAI_PROVIDER_KEY,
|
||||
OPENAI_OAUTH_PROFILE_NAME,
|
||||
token_set_from_codex(token),
|
||||
);
|
||||
if let Some(account_id) = extract_account_id_from_access_token(&token.access_token) {
|
||||
profile
|
||||
.metadata
|
||||
.insert("account_id".to_string(), account_id);
|
||||
}
|
||||
|
||||
let store = auth_profiles_store(config);
|
||||
store
|
||||
.upsert_profile(profile.clone(), true)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
fn auth_profiles_store(config: &Config) -> AuthProfilesStore {
|
||||
AuthProfilesStore::new(&state_dir_from_config(config), config.secrets.encrypt)
|
||||
}
|
||||
|
||||
fn try_refresh_oauth_token(refresh: &str) -> Result<Token, String> {
|
||||
let cfg = codex_oauth_config();
|
||||
let fut = motosan_ai_oauth::refresh(&cfg, refresh);
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
return handle.block_on(fut).map_err(|e| e.to_string());
|
||||
}
|
||||
Err("tokio runtime required to refresh openai oauth token".to_string())
|
||||
}
|
||||
|
||||
fn extract_account_id_from_access_token(access_token: &str) -> Option<String> {
|
||||
let payload = access_token.split('.').nth(1)?;
|
||||
let padded = match payload.len() % 4 {
|
||||
0 => payload.to_string(),
|
||||
n => format!("{}{}", payload, "=".repeat(4 - n)),
|
||||
};
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(padded.as_bytes())
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(padded.as_bytes()))
|
||||
.ok()?;
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
json.get("sub")
|
||||
.or_else(|| json.get("account_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn lookup_openai_bearer_token(config: &Config) -> Result<Option<String>, String> {
|
||||
let auth = AuthService::from_config(config);
|
||||
let slug = "openai";
|
||||
let new_key = auth_key_for_slug(slug);
|
||||
|
||||
if let Ok(Some(k)) = auth.get_provider_bearer_token(&new_key, None) {
|
||||
if !k.is_empty() {
|
||||
return Ok(Some(k));
|
||||
}
|
||||
}
|
||||
if let Ok(Some(k)) = auth.get_provider_bearer_token(slug, None) {
|
||||
if !k.is_empty() {
|
||||
return Ok(Some(k));
|
||||
}
|
||||
}
|
||||
|
||||
let profile = auth
|
||||
.get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(mut profile) = profile else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(mut token_set) = profile.token_set.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let skew = Duration::minutes(2);
|
||||
if token_set.is_expiring_within(std::time::Duration::from_secs(
|
||||
skew.num_seconds().unsigned_abs(),
|
||||
)) {
|
||||
if let Some(refresh) = token_set.refresh_token.clone() {
|
||||
match try_refresh_oauth_token(&refresh) {
|
||||
Ok(fresh) => {
|
||||
token_set = token_set_from_codex(&fresh);
|
||||
profile.token_set = Some(token_set.clone());
|
||||
let _ = auth_profiles_store(config).upsert_profile(profile, true);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{LOG_PREFIX} oauth refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let access = token_set.access_token.trim();
|
||||
if access.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(access.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,48 @@ pub async fn inference_apply_preset(tier: &str) -> Result<RpcOutcome<Value>, Str
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn inference_openai_oauth_start(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
let start = crate::openhuman::inference::openai_oauth::start_openai_oauth(config)?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
json!({
|
||||
"authUrl": start.auth_url,
|
||||
"state": start.state,
|
||||
"redirectUri": start.redirect_uri,
|
||||
}),
|
||||
"openai oauth authorize url ready",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn inference_openai_oauth_complete(
|
||||
config: &Config,
|
||||
callback_url: &str,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
let result =
|
||||
crate::openhuman::inference::openai_oauth::complete_openai_oauth(config, callback_url)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(result, "openai oauth connected"))
|
||||
}
|
||||
|
||||
pub async fn inference_openai_oauth_status(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
let status = crate::openhuman::inference::openai_oauth::openai_oauth_status(config)?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
json!({
|
||||
"connected": status.connected,
|
||||
"profileId": status.profile_id,
|
||||
"expiresAt": status.expires_at,
|
||||
"authMethod": status.auth_method,
|
||||
}),
|
||||
"openai oauth status",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn inference_openai_oauth_disconnect(
|
||||
config: &Config,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
let result = crate::openhuman::inference::openai_oauth::disconnect_openai_oauth(config)?;
|
||||
Ok(RpcOutcome::single_log(result, "openai oauth disconnected"))
|
||||
}
|
||||
|
||||
pub async fn inference_diagnostics(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
debug!("{LOG_PREFIX} diagnostics:start");
|
||||
let service = local_runtime::global(config);
|
||||
|
||||
@@ -413,6 +413,12 @@ fn make_cloud_provider_by_slug(
|
||||
/// "no auth", which surfaces an authentication error at first call rather than
|
||||
/// at factory build time.
|
||||
pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result<String> {
|
||||
if slug == "openai" {
|
||||
return crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token(config)
|
||||
.map_err(|e| anyhow::anyhow!("[chat-factory] openai auth lookup failed: {e}"))
|
||||
.map(|opt| opt.unwrap_or_default());
|
||||
}
|
||||
|
||||
let auth = AuthService::from_config(config);
|
||||
// Try new-style key first.
|
||||
let new_key = auth_key_for_slug(slug);
|
||||
|
||||
@@ -121,6 +121,12 @@ struct InferenceApplyPresetParams {
|
||||
tier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InferenceOpenAiOAuthCompleteParams {
|
||||
#[serde(alias = "callbackUrl")]
|
||||
callback_url: String,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("status"),
|
||||
@@ -132,6 +138,10 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("presets"),
|
||||
schemas("apply_preset"),
|
||||
schemas("diagnostics"),
|
||||
schemas("openai_oauth_start"),
|
||||
schemas("openai_oauth_complete"),
|
||||
schemas("openai_oauth_status"),
|
||||
schemas("openai_oauth_disconnect"),
|
||||
schemas("summarize"),
|
||||
schemas("prompt"),
|
||||
schemas("vision_prompt"),
|
||||
@@ -180,6 +190,22 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("diagnostics"),
|
||||
handler: handle_inference_diagnostics,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("openai_oauth_start"),
|
||||
handler: handle_inference_openai_oauth_start,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("openai_oauth_complete"),
|
||||
handler: handle_inference_openai_oauth_complete,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("openai_oauth_status"),
|
||||
handler: handle_inference_openai_oauth_status,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("openai_oauth_disconnect"),
|
||||
handler: handle_inference_openai_oauth_disconnect,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("summarize"),
|
||||
handler: handle_inference_summarize,
|
||||
@@ -313,6 +339,37 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
via `issues`.",
|
||||
)],
|
||||
},
|
||||
"openai_oauth_start" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "openai_oauth_start",
|
||||
description: "Begin ChatGPT/Codex OAuth (PKCE) for the openai cloud provider.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("result", "OAuth start payload with authUrl.")],
|
||||
},
|
||||
"openai_oauth_complete" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "openai_oauth_complete",
|
||||
description: "Complete ChatGPT/Codex OAuth using the browser callback URL.",
|
||||
inputs: vec![required_string(
|
||||
"callback_url",
|
||||
"Redirect URL after sign-in (http://127.0.0.1:1455/auth/callback?...).",
|
||||
)],
|
||||
outputs: vec![json_output("result", "OAuth completion payload.")],
|
||||
},
|
||||
"openai_oauth_status" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "openai_oauth_status",
|
||||
description: "Whether ChatGPT OAuth credentials are stored for openai.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("status", "OAuth connection status.")],
|
||||
},
|
||||
"openai_oauth_disconnect" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "openai_oauth_disconnect",
|
||||
description: "Remove stored ChatGPT OAuth credentials.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("result", "Disconnect result.")],
|
||||
},
|
||||
"summarize" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "summarize",
|
||||
@@ -613,6 +670,43 @@ fn handle_inference_apply_preset(params: Map<String, Value>) -> ControllerFuture
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_openai_oauth_start(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(crate::openhuman::inference::rpc::inference_openai_oauth_start(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_openai_oauth_complete(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let payload = deserialize_params::<InferenceOpenAiOAuthCompleteParams>(params)?;
|
||||
to_json(
|
||||
crate::openhuman::inference::rpc::inference_openai_oauth_complete(
|
||||
&config,
|
||||
payload.callback_url.trim(),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_openai_oauth_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(crate::openhuman::inference::rpc::inference_openai_oauth_status(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_openai_oauth_disconnect(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::inference::rpc::inference_openai_oauth_disconnect(&config).await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_diagnostics(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
@@ -5,7 +5,7 @@ fn inference_catalog_counts_match_and_nonempty() {
|
||||
let declared = all_controller_schemas();
|
||||
let registered = all_registered_controllers();
|
||||
assert_eq!(declared.len(), registered.len());
|
||||
assert!(declared.len() >= 16);
|
||||
assert!(declared.len() >= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user