Add OpenAI-compatible provider mode

This commit is contained in:
Gerard-Devlin 2026-06-08 02:06:33 +08:00
parent 77d464da24
commit c2b605f5a7
3 changed files with 501 additions and 16 deletions

View file

@ -1,3 +1,4 @@
use serde_json::json;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
@ -10,8 +11,12 @@ use tokio::sync::Mutex;
#[derive(Default, serde::Deserialize, serde::Serialize)]
#[serde(default)]
struct ClaudePrismAuthConfig {
provider: Option<String>,
anthropic_api_key: Option<String>,
anthropic_base_url: Option<String>,
openai_api_key: Option<String>,
openai_base_url: Option<String>,
openai_model: Option<String>,
}
struct StoredClaudeCredential {
@ -19,6 +24,22 @@ struct StoredClaudeCredential {
base_url: Option<String>,
}
#[derive(Clone)]
struct StoredOpenAiCompatibleCredential {
api_key: String,
base_url: String,
model: String,
}
#[derive(Clone, serde::Deserialize, serde::Serialize)]
struct DirectProviderMessage {
role: String,
content: String,
}
const PROVIDER_CLAUDE_CODE: &str = "claude-code";
const PROVIDER_OPENAI_COMPATIBLE: &str = "openai-compatible";
/// Check if an environment variable should be explicitly passed to child processes.
///
/// NOTE: This is NOT a true whitelist — we do NOT call `env_clear()`, so the
@ -116,8 +137,39 @@ fn normalize_base_url(value: Option<&str>) -> Result<Option<String>, String> {
Ok(Some(clean))
}
fn normalize_provider(value: Option<&str>) -> Result<String, String> {
let provider = value.unwrap_or(PROVIDER_CLAUDE_CODE).trim();
match provider {
"" | PROVIDER_CLAUDE_CODE => Ok(PROVIDER_CLAUDE_CODE.to_string()),
PROVIDER_OPENAI_COMPATIBLE => Ok(PROVIDER_OPENAI_COMPATIBLE.to_string()),
other => Err(format!("Unsupported provider: {}", other)),
}
}
fn normalize_model(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let clean = strip_nul(value).trim().to_string();
if clean.is_empty() {
return Ok(None);
}
if clean.chars().any(char::is_whitespace) {
return Err("Model cannot contain spaces or line breaks".to_string());
}
Ok(Some(clean))
}
fn stored_claude_credential() -> Option<StoredClaudeCredential> {
let config = read_claude_prism_auth_config().ok()?;
let provider = normalize_provider(config.provider.as_deref()).ok()?;
if provider != PROVIDER_CLAUDE_CODE {
return None;
}
let api_key = config
.anthropic_api_key
.and_then(|value| normalize_api_key(&value).ok())?;
@ -130,6 +182,30 @@ fn stored_claude_credential() -> Option<StoredClaudeCredential> {
Some(StoredClaudeCredential { api_key, base_url })
}
fn stored_openai_compatible_credential() -> Option<StoredOpenAiCompatibleCredential> {
let config = read_claude_prism_auth_config().ok()?;
let provider = normalize_provider(config.provider.as_deref()).ok()?;
if provider != PROVIDER_OPENAI_COMPATIBLE {
return None;
}
let api_key = config
.openai_api_key
.and_then(|value| normalize_api_key(&value).ok())?;
let base_url = normalize_base_url(config.openai_base_url.as_deref())
.ok()
.flatten()?;
let model = normalize_model(config.openai_model.as_deref())
.ok()
.flatten()?;
Some(StoredOpenAiCompatibleCredential {
api_key,
base_url,
model,
})
}
fn claude_credential_label() -> Option<&'static str> {
if std::env::var("ANTHROPIC_API_KEY")
.map(|value| !value.trim().is_empty())
@ -166,9 +242,25 @@ fn claude_credential_label() -> Option<&'static str> {
pub async fn save_anthropic_api_key(
api_key: String,
base_url: Option<String>,
provider: Option<String>,
model: Option<String>,
) -> Result<(), String> {
let api_key = normalize_api_key(&api_key)?;
let base_url = normalize_base_url(base_url.as_deref())?;
let provider = normalize_provider(provider.as_deref())?;
let model = normalize_model(model.as_deref())?;
let mut config = read_claude_prism_auth_config()?;
config.provider = Some(provider.clone());
if provider == PROVIDER_OPENAI_COMPATIBLE {
let base_url = base_url.ok_or("OpenAI-compatible provider requires a Base URL")?;
let model = model.ok_or("OpenAI-compatible provider requires a model")?;
config.openai_api_key = Some(api_key);
config.openai_base_url = Some(base_url);
config.openai_model = Some(model);
return write_claude_prism_auth_config(&config);
}
if base_url.is_none() && !api_key.starts_with("sk-ant-") {
return Err(
@ -177,7 +269,6 @@ pub async fn save_anthropic_api_key(
);
}
let mut config = read_claude_prism_auth_config()?;
config.anthropic_api_key = Some(api_key);
config.anthropic_base_url = base_url;
write_claude_prism_auth_config(&config)
@ -186,8 +277,12 @@ pub async fn save_anthropic_api_key(
#[tauri::command]
pub async fn clear_anthropic_api_key() -> Result<(), String> {
let mut config = read_claude_prism_auth_config()?;
config.provider = Some(PROVIDER_CLAUDE_CODE.to_string());
config.anthropic_api_key = None;
config.anthropic_base_url = None;
config.openai_api_key = None;
config.openai_base_url = None;
config.openai_model = None;
write_claude_prism_auth_config(&config)
}
@ -202,12 +297,14 @@ use std::os::windows::process::CommandExt;
#[derive(Clone)]
pub struct ClaudeProcessState {
pub processes: Arc<Mutex<HashMap<String, Child>>>,
direct_sessions: Arc<Mutex<HashMap<String, Vec<DirectProviderMessage>>>>,
}
impl Default for ClaudeProcessState {
fn default() -> Self {
Self {
processes: Arc::new(Mutex::new(HashMap::new())),
direct_sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
}
@ -1268,6 +1365,17 @@ fn find_git_bash() -> Option<String> {
#[tauri::command]
pub async fn check_claude_status() -> Result<ClaudeStatus, String> {
if let Some(credential) = stored_openai_compatible_credential() {
return Ok(ClaudeStatus {
installed: true,
authenticated: true,
binary_path: None,
version: Some("OpenAI-compatible provider".to_string()),
account_email: Some(format!("{} · {}", credential.model, credential.base_url)),
missing_git: false,
});
}
// On Windows, check for Git for Windows first — Claude Code requires it.
#[cfg(target_os = "windows")]
let missing_git = find_git_bash().is_none();
@ -1697,6 +1805,254 @@ fn common_claude_args() -> Vec<String> {
// ─── Tauri Commands ───
fn direct_provider_system_prompt() -> String {
[
"You are an AI assistant integrated into ClaudePrism, a LaTeX document editor.",
"Help the user write, revise, and reason about academic documents.",
"Preserve existing LaTeX structure unless the user asks for a rewrite.",
"Use proper LaTeX sectioning, citations, labels, references, and bibliography conventions.",
"In direct provider mode you may not have Claude Code tool execution yet. If you cannot directly edit files, provide precise patches or replacement snippets.",
]
.join("\n")
}
fn openai_chat_completions_url(base_url: &str) -> String {
let clean = base_url.trim_end_matches('/');
if clean.ends_with("/chat/completions") {
clean.to_string()
} else if clean.ends_with("/v1") || clean.ends_with("/openai/v1") {
format!("{}/chat/completions", clean)
} else {
format!("{}/v1/chat/completions", clean)
}
}
fn json_usage(value: &serde_json::Value) -> serde_json::Value {
let input_tokens = value
.pointer("/usage/prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output_tokens = value
.pointer("/usage/completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens,
})
}
async fn execute_openai_compatible_provider(
window: WebviewWindow,
project_path: String,
prompt: String,
tab_id: String,
session_id: Option<String>,
credential: StoredOpenAiCompatibleCredential,
) -> Result<(), String> {
let started = std::time::Instant::now();
let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let init = json!({
"type": "system",
"subtype": "init",
"session_id": session_id,
"model": credential.model.clone(),
"cwd": project_path,
"tools": [],
});
let _ = window.emit(
"claude-output",
ClaudeOutputEvent {
tab_id: tab_id.clone(),
data: init.to_string(),
},
);
let state = window.state::<ClaudeProcessState>();
let prior_messages = {
let sessions = state.direct_sessions.lock().await;
sessions.get(&session_id).cloned().unwrap_or_default()
};
let mut messages = vec![json!({
"role": "system",
"content": direct_provider_system_prompt(),
})];
for message in &prior_messages {
messages.push(json!({
"role": message.role,
"content": message.content,
}));
}
messages.push(json!({
"role": "user",
"content": prompt,
}));
let request_body = json!({
"model": credential.model.clone(),
"messages": messages,
"stream": false,
});
let client = reqwest::Client::new();
let url = openai_chat_completions_url(&credential.base_url);
let response = match client
.post(url)
.bearer_auth(&credential.api_key)
.header("Content-Type", "application/json")
.body(request_body.to_string())
.send()
.await
{
Ok(response) => response,
Err(err) => {
let _ = window.emit(
"claude-error",
ClaudeErrorEvent {
tab_id: tab_id.clone(),
data: format!("Provider request failed: {}", err),
},
);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: false,
},
);
return Ok(());
}
};
let status = response.status();
let response_text = response
.text()
.await
.map_err(|err| format!("Failed to read provider response: {}", err))?;
if !status.is_success() {
let _ = window.emit(
"claude-error",
ClaudeErrorEvent {
tab_id: tab_id.clone(),
data: format!("Provider returned HTTP {}: {}", status, response_text),
},
);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: false,
},
);
return Ok(());
}
let value: serde_json::Value = match serde_json::from_str(&response_text) {
Ok(value) => value,
Err(err) => {
let _ = window.emit(
"claude-error",
ClaudeErrorEvent {
tab_id: tab_id.clone(),
data: format!("Provider returned invalid JSON: {}", err),
},
);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: false,
},
);
return Ok(());
}
};
let content = value
.pointer("/choices/0/message/content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if content.trim().is_empty() {
let _ = window.emit(
"claude-error",
ClaudeErrorEvent {
tab_id: tab_id.clone(),
data: "Provider response did not include message content".to_string(),
},
);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: false,
},
);
return Ok(());
}
let usage = json_usage(&value);
let assistant = json!({
"type": "assistant",
"message": {
"content": [{ "type": "text", "text": content }],
"usage": usage,
},
});
let _ = window.emit(
"claude-output",
ClaudeOutputEvent {
tab_id: tab_id.clone(),
data: assistant.to_string(),
},
);
{
let mut sessions = state.direct_sessions.lock().await;
let history = sessions.entry(session_id.clone()).or_default();
history.push(DirectProviderMessage {
role: "user".to_string(),
content: prompt,
});
history.push(DirectProviderMessage {
role: "assistant".to_string(),
content: content.clone(),
});
}
let elapsed_ms = started.elapsed().as_millis() as u64;
let result = json!({
"type": "result",
"subtype": "success",
"is_error": false,
"result": content,
"duration_ms": elapsed_ms,
"duration_api_ms": elapsed_ms,
"num_turns": 1,
"usage": usage,
});
let _ = window.emit(
"claude-output",
ClaudeOutputEvent {
tab_id: tab_id.clone(),
data: result.to_string(),
},
);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: true,
},
);
Ok(())
}
#[tauri::command]
pub async fn execute_claude_code(
window: WebviewWindow,
@ -1706,6 +2062,18 @@ pub async fn execute_claude_code(
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
if let Some(credential) = stored_openai_compatible_credential() {
return execute_openai_compatible_provider(
window,
project_path,
prompt,
tab_id,
None,
credential,
)
.await;
}
let claude_path = find_claude_binary()?;
let (mut args, stdin_payload) = with_prompt_transport(Vec::new(), prompt);
@ -1728,6 +2096,18 @@ pub async fn continue_claude_code(
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
if let Some(credential) = stored_openai_compatible_credential() {
return execute_openai_compatible_provider(
window,
project_path,
prompt,
tab_id,
None,
credential,
)
.await;
}
let claude_path = find_claude_binary()?;
let (mut args, stdin_payload) = with_prompt_transport(vec!["-c".to_string()], prompt);
@ -1751,6 +2131,18 @@ pub async fn resume_claude_code(
model: Option<String>,
effort_level: Option<String>,
) -> Result<(), String> {
if let Some(credential) = stored_openai_compatible_credential() {
return execute_openai_compatible_provider(
window,
project_path,
prompt,
tab_id,
Some(session_id),
credential,
)
.await;
}
let claude_path = find_claude_binary()?;
let (mut args, stdin_payload) =

View file

@ -243,8 +243,12 @@ function InstallLogOutput() {
// ─── Main Component ───
export function ClaudeSetup() {
const [provider, setProvider] = useState<"claude-code" | "openai-compatible">(
"claude-code",
);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [model, setModel] = useState("");
const status = useClaudeSetupStore((s) => s.status);
const isInstalling = useClaudeSetupStore((s) => s.isInstalling);
const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn);
@ -263,10 +267,11 @@ export function ClaudeSetup() {
useLoginEvents();
const handleSaveApiKey = async () => {
const success = await saveApiKey(apiKey, baseUrl);
const success = await saveApiKey(apiKey, baseUrl, provider, model);
if (success) {
setApiKey("");
setBaseUrl("");
setModel("");
}
};
@ -282,11 +287,14 @@ export function ClaudeSetup() {
}
if (status === "ready") {
const isDirectProvider = version === "OpenAI-compatible provider";
return (
<div className="flex w-full items-center gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<CheckCircle2Icon className="size-5 shrink-0 text-green-600" />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">Claude Code Ready</p>
<p className="font-medium text-sm">
{isDirectProvider ? "AI Provider Ready" : "Claude Code Ready"}
</p>
<p className="truncate text-muted-foreground text-xs">
{[version, accountEmail].filter(Boolean).join(" · ")}
</p>
@ -478,22 +486,50 @@ export function ClaudeSetup() {
handleSaveApiKey();
}}
>
<div className="space-y-1.5">
<Label htmlFor="ai-provider" className="text-xs">
Provider
</Label>
<select
id="ai-provider"
value={provider}
onChange={(event) =>
setProvider(
event.target.value as "claude-code" | "openai-compatible",
)
}
disabled={isSavingApiKey}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none transition-colors focus-visible:border-ring"
>
<option value="claude-code">Claude Code / Anthropic API</option>
<option value="openai-compatible">OpenAI-compatible API</option>
</select>
<p className="text-[11px] text-muted-foreground">
Use OpenAI-compatible for Qwen, DeepSeek, GLM, and compatible
gateways.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="anthropic-api-key" className="text-xs">
Anthropic API Key
{provider === "openai-compatible"
? "Provider API Key"
: "Anthropic API Key"}
</Label>
<Input
id="anthropic-api-key"
type="password"
placeholder="sk-ant-..."
placeholder={
provider === "openai-compatible" ? "sk-..." : "sk-ant-..."
}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={isSavingApiKey}
autoComplete="off"
/>
<p className="text-muted-foreground text-[11px]">
Anthropic Console keys start with sk-ant-. External provider keys
need a Base URL.
<p className="text-[11px] text-muted-foreground">
{provider === "openai-compatible"
? "Use the API key from your model provider."
: "Anthropic Console keys start with sk-ant-. External Claude proxies need a Base URL."}
</p>
</div>
<div className="space-y-1.5">
@ -503,16 +539,41 @@ export function ClaudeSetup() {
<Input
id="anthropic-base-url"
type="url"
placeholder="https://proxy.example.com/claude"
placeholder={
provider === "openai-compatible"
? "https://api.deepseek.com or https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://proxy.example.com/claude"
}
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
disabled={isSavingApiKey}
autoComplete="off"
/>
<p className="text-muted-foreground text-[11px]">
Leave blank for Anthropic direct API.
<p className="text-[11px] text-muted-foreground">
{provider === "openai-compatible"
? "Use either the API root or a full /chat/completions URL."
: "Leave blank for Anthropic direct API."}
</p>
</div>
{provider === "openai-compatible" && (
<div className="space-y-1.5">
<Label htmlFor="provider-model" className="text-xs">
Model
</Label>
<Input
id="provider-model"
type="text"
placeholder="deepseek-chat, qwen-plus, glm-4.5, ..."
value={model}
onChange={(event) => setModel(event.target.value)}
disabled={isSavingApiKey}
autoComplete="off"
/>
<p className="text-[11px] text-muted-foreground">
This model is used directly and ignores the Claude model picker.
</p>
</div>
)}
{error && (
<p className="break-words text-destructive text-xs">{error}</p>
)}
@ -520,7 +581,12 @@ export function ClaudeSetup() {
type="submit"
size="sm"
className="w-full gap-2"
disabled={!apiKey.trim() || isSavingApiKey}
disabled={
!apiKey.trim() ||
isSavingApiKey ||
(provider === "openai-compatible" &&
(!baseUrl.trim() || !model.trim()))
}
>
{isSavingApiKey ? (
<LoaderIcon className="size-3.5 animate-spin" />
@ -533,7 +599,7 @@ export function ClaudeSetup() {
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-[11px]">or</span>
<span className="text-[11px] text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border" />
</div>

View file

@ -49,7 +49,12 @@ interface ClaudeSetupState {
checkStatus: () => Promise<void>;
install: () => Promise<void>;
login: () => Promise<void>;
saveApiKey: (apiKey: string, baseUrl?: string) => Promise<boolean>;
saveApiKey: (
apiKey: string,
baseUrl?: string,
provider?: string,
model?: string,
) => Promise<boolean>;
toggleInstallLogs: () => void;
// Internal helpers
@ -207,9 +212,15 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
}
},
saveApiKey: async (apiKey: string, baseUrl?: string) => {
saveApiKey: async (
apiKey: string,
baseUrl?: string,
provider = "claude-code",
model?: string,
) => {
const key = apiKey.trim();
const url = baseUrl?.trim() ?? "";
const modelName = model?.trim() ?? "";
if (!key) {
set({ error: "API key is empty" });
return false;
@ -225,7 +236,21 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
return false;
}
if (!url && !key.startsWith("sk-ant-")) {
if (provider === "openai-compatible" && !url) {
set({ error: "OpenAI-compatible provider requires a Base URL." });
return false;
}
if (provider === "openai-compatible" && !modelName) {
set({ error: "OpenAI-compatible provider requires a model." });
return false;
}
if (
provider !== "openai-compatible" &&
!url &&
!key.startsWith("sk-ant-")
) {
set({
error:
"This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-.",
@ -238,6 +263,8 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
await invoke("save_anthropic_api_key", {
apiKey: key,
baseUrl: url || null,
provider,
model: modelName || null,
});
set({ isSavingApiKey: false });
await get().checkStatus();