mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-07 02:58:00 +00:00
Add OpenAI-compatible provider mode
This commit is contained in:
parent
77d464da24
commit
c2b605f5a7
3 changed files with 501 additions and 16 deletions
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue