feat(providers): Moonshot AI provider + extract shared OpenAI-compat helpers
- Add MoonshotProvider and MoonshotCodeProvider (OpenAI-compatible)
- Extract `fetch_openai_models()` and `build_openai_llm()` shared functions,
deduplicating model-listing and client construction across OpenAI,
OpenRouter, DeepSeek, LM Studio, and Z.AI providers
- Add `lists_models` field to `ProviderUiMeta` — drives frontend model
picker dynamically instead of hardcoded `type_id` list
- Port DeepSeek, LM Studio, OpenRouter model listing to `fetch_openai_models`
- Set `lists_models: true` on Z.AI provider (missed in prior pass)
feat(mcp): named key placeholders {SECRET:name}/{ENV:name} in connector URLs
- `apply_key_placeholder` now accepts an `env` map alongside `api_key`
- Named tokens resolve from the connector's described `env[]` fields,
with `{SECRET:name}` falling back to `api_key` for backward compat
- Replace `substitute_secret_tokens` with `substitute_named_tokens`
- Unresolved tokens are left in place (visible misconfig) rather than
silently producing a wrong URL
fix(ui): connector detail hides generic API key when schema has secret
fix(ui): model picker uses `lists_models` from provider types endpoint
This commit is contained in:
@@ -111,6 +111,7 @@ impl ApiProvider for AnthropicProvider {
|
||||
description: None,
|
||||
color: "#d4a574",
|
||||
icon: "bi-chat-square-dots",
|
||||
lists_models: false,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm, fetch_openai_models};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
|
||||
|
||||
pub struct DeepSeekProvider {
|
||||
@@ -50,21 +47,8 @@ impl ApiProvider for DeepSeekProvider {
|
||||
let api_key = record.api_key.as_deref()
|
||||
.ok_or_else(|| anyhow!("provider '{}': api_key required for deepseek model listing", record.name))?;
|
||||
|
||||
let resp: serde_json::Value = self.http
|
||||
.get("https://api.deepseek.com/models")
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("DeepSeek request failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| anyhow!("DeepSeek error response: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("DeepSeek response parse failed: {e}"))?;
|
||||
|
||||
let models = resp["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("unexpected DeepSeek response shape"))?
|
||||
let raw = fetch_openai_models(&self.http, "https://api.deepseek.com", Some(api_key), "DeepSeek").await?;
|
||||
let models = raw
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?.to_string();
|
||||
@@ -120,15 +104,7 @@ impl ApiProvider for DeepSeekProvider {
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some((|| {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for deepseek", record.name))?;
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new("https://api.deepseek.com/v1", key, extra, false)),
|
||||
prompt_cache: false,
|
||||
})
|
||||
})())
|
||||
Some(build_openai_llm(self, "https://api.deepseek.com/v1", record, model, false))
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta {
|
||||
@@ -138,6 +114,7 @@ impl ApiProvider for DeepSeekProvider {
|
||||
description: None,
|
||||
color: "#0ea5e9",
|
||||
icon: "bi-search",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::chatbot::lm_studio::LmStudioClient;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::RemoteLlmModelInfo;
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, fetch_openai_models};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType};
|
||||
|
||||
pub struct LmStudioProvider {
|
||||
@@ -31,19 +31,9 @@ impl ApiProvider for LmStudioProvider {
|
||||
}
|
||||
|
||||
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
|
||||
let url = format!("{}/models", Self::base_url(record).trim_end_matches('/'));
|
||||
let resp: serde_json::Value = self.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("LM Studio request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("LM Studio response parse failed: {e}"))?;
|
||||
let raw = fetch_openai_models(&self.http, &Self::base_url(record), None, "LM Studio").await?;
|
||||
|
||||
let models = resp["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("unexpected LM Studio response shape"))?
|
||||
let models = raw
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?.to_string();
|
||||
@@ -78,6 +68,7 @@ impl ApiProvider for LmStudioProvider {
|
||||
description: Some("Local models via LM Studio"),
|
||||
color: "#6b7280",
|
||||
icon: "bi-window-stack",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "base_url", label: "Base URL", required: false, secret: false },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod anthropic;
|
||||
pub mod deepseek;
|
||||
pub mod lm_studio;
|
||||
pub mod moonshot;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openrouter;
|
||||
@@ -10,7 +11,13 @@ pub mod zai;
|
||||
pub use crate::provider::ServiceType;
|
||||
pub use core_api::provider::RemoteLlmModelInfo;
|
||||
|
||||
use core_api::provider::{ApiProvider, LlmModelRecord};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use core_api::provider::{ApiProvider, BuiltLlmClient, LlmModelRecord, LlmProviderRecord};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
|
||||
/// Computes the `extra_params` an OpenAI-compatible client should be built with,
|
||||
/// given a model's stored `extra_params` and its selected reasoning value. The
|
||||
@@ -37,3 +44,54 @@ pub(crate) fn extra_with_reasoning(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the OpenAI-style `GET {base_url}/models` catalog shared by most
|
||||
/// OpenAI-compatible providers. Returns the raw per-model JSON objects from
|
||||
/// the `data` envelope so each provider can map and enrich them with its own
|
||||
/// heuristics. `api_key` is sent as a bearer token when present (local
|
||||
/// providers pass `None`); `who` is the display name used in error messages.
|
||||
pub(crate) async fn fetch_openai_models(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
who: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let mut req = http.get(&url);
|
||||
if let Some(key) = api_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
let resp: serde_json::Value = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{who} request failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| anyhow!("{who} error response: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{who} response parse failed: {e}"))?;
|
||||
|
||||
resp["data"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("unexpected {who} response shape"))
|
||||
}
|
||||
|
||||
/// Builds an `OpenAiClient` for an OpenAI-compatible provider: requires the
|
||||
/// provider record's `api_key` and merges the model's stored `extra_params`
|
||||
/// with the provider-translated reasoning fragment (see `extra_with_reasoning`).
|
||||
pub(crate) fn build_openai_llm(
|
||||
provider: &dyn ApiProvider,
|
||||
base_url: &str,
|
||||
record: &LlmProviderRecord,
|
||||
model: &LlmModelRecord,
|
||||
prompt_cache: bool,
|
||||
) -> Result<BuiltLlmClient> {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for {}", record.name, provider.type_id()))?;
|
||||
let extra = extra_with_reasoning(provider, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new(base_url, key, extra, prompt_cache)),
|
||||
prompt_cache,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm, fetch_openai_models};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
|
||||
|
||||
/// Moonshot AI — pay-as-you-go platform.
|
||||
///
|
||||
/// Endpoint `https://api.moonshot.ai/v1/chat/completions` (OpenAI-compatible);
|
||||
/// `OpenAiClient` appends `/chat/completions`, so the base URL is `.../v1`.
|
||||
///
|
||||
/// `GET /models` returns the full catalog including `context_length`,
|
||||
/// `supports_image_in` and `supports_reasoning`, so the model list is entirely
|
||||
/// endpoint-driven. Thinking models (kimi-k2-thinking…) always reason — the
|
||||
/// platform exposes no request-level knob, hence no `ReasoningMode`.
|
||||
pub struct MoonshotProvider {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Moonshot AI — Kimi Code subscription.
|
||||
///
|
||||
/// Endpoint `https://api.kimi.com/coding/v1/chat/completions` (OpenAI-compatible).
|
||||
/// The model catalog comes from `GET /models` like the platform; only the
|
||||
/// metadata the endpoint omits (context size, vision) is filled from the
|
||||
/// published docs. K3 is the only model with a reasoning knob: a graded
|
||||
/// `reasoning_effort` (`low`/`high`/`max`, default `max`); the kimi-for-coding
|
||||
/// series (K2.7 Code) always thinks and has no toggle.
|
||||
pub struct MoonshotCodeProvider {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MoonshotProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { http: reqwest::Client::new() }
|
||||
}
|
||||
|
||||
const BASE_URL: &'static str = "https://api.moonshot.ai/v1";
|
||||
}
|
||||
|
||||
impl MoonshotCodeProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { http: reqwest::Client::new() }
|
||||
}
|
||||
|
||||
const BASE_URL: &'static str = "https://api.kimi.com/coding/v1";
|
||||
|
||||
/// Fills the metadata the Kimi Code `/models` endpoint may omit, from the
|
||||
/// published docs: K3 → up to 1M context + native visual understanding;
|
||||
/// the kimi-for-coding series → 256k. Values already present in the
|
||||
/// endpoint response (e.g. a tier-specific context size) always win.
|
||||
fn enrich(info: &mut RemoteLlmModelInfo) {
|
||||
let id = info.id.to_lowercase();
|
||||
if info.context_length.is_none() {
|
||||
if id.starts_with("k3") {
|
||||
info.context_length = Some(1_048_576);
|
||||
} else if id.starts_with("kimi-for-coding") {
|
||||
info.context_length = Some(262_144);
|
||||
}
|
||||
}
|
||||
if info.vision.is_none() && id.starts_with("k3") {
|
||||
info.vision = Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared model listing for both Moonshot APIs: same envelope, same per-model
|
||||
/// fields (`context_length`, `supports_image_in`, `supports_reasoning` — all
|
||||
/// optional, absent fields are left for the caller to enrich).
|
||||
async fn list_models(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
record: &LlmProviderRecord,
|
||||
who: &str,
|
||||
) -> Result<Vec<RemoteLlmModelInfo>> {
|
||||
let api_key = record.api_key.as_deref()
|
||||
.ok_or_else(|| anyhow!("provider '{}': api_key required for {who} model listing", record.name))?;
|
||||
|
||||
let raw = fetch_openai_models(http, base_url, Some(api_key), who).await?;
|
||||
let models = raw
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?.to_string();
|
||||
let mut capabilities = vec!["function_calling".to_string()];
|
||||
if m["supports_reasoning"].as_bool().unwrap_or(false) {
|
||||
capabilities.push("reasoning".to_string());
|
||||
}
|
||||
if m["supports_image_in"].as_bool().unwrap_or(false) {
|
||||
capabilities.push("vision".to_string());
|
||||
}
|
||||
Some(RemoteLlmModelInfo {
|
||||
id,
|
||||
name: m["id"].as_str()?.to_string(),
|
||||
context_length: m["context_length"].as_u64(),
|
||||
max_completion_tokens: None,
|
||||
knowledge_cutoff: None,
|
||||
capabilities,
|
||||
vision: m["supports_image_in"].as_bool(),
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
reasoning: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ApiProvider for MoonshotProvider {
|
||||
fn type_id(&self) -> &'static str { "moonshot" }
|
||||
fn display_name(&self) -> &'static str { "Moonshot AI pay-as-you-go" }
|
||||
fn supported_types(&self) -> &'static [ServiceType] {
|
||||
&[ServiceType::Llm]
|
||||
}
|
||||
|
||||
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
|
||||
Ok(Some(list_models(&self.http, Self::BASE_URL, record, "Moonshot AI").await?))
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some(build_openai_llm(self, Self::BASE_URL, record, model, false))
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta {
|
||||
ProviderUiMeta {
|
||||
type_id: "moonshot",
|
||||
display_name: "Moonshot AI pay-as-you-go",
|
||||
description: Some("Kimi models on the Moonshot AI platform (OpenAI-compatible)"),
|
||||
color: "#2563eb",
|
||||
icon: "bi-moon-stars",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ApiProvider for MoonshotCodeProvider {
|
||||
fn type_id(&self) -> &'static str { "moonshot_code" }
|
||||
fn display_name(&self) -> &'static str { "Moonshot AI Kimi Code" }
|
||||
fn supported_types(&self) -> &'static [ServiceType] {
|
||||
&[ServiceType::Llm]
|
||||
}
|
||||
|
||||
async fn list_llm_models(&self, record: &LlmProviderRecord) -> Result<Option<Vec<RemoteLlmModelInfo>>> {
|
||||
let mut models = list_models(&self.http, Self::BASE_URL, record, "Kimi Code").await?;
|
||||
for m in &mut models {
|
||||
Self::enrich(m);
|
||||
}
|
||||
Ok(Some(models))
|
||||
}
|
||||
|
||||
fn reasoning_mode(&self, model_id: &str, _capabilities: &[String]) -> Option<ReasoningMode> {
|
||||
let id = model_id.to_lowercase();
|
||||
// K3 exposes a graded `reasoning_effort` (low/high/max; default max).
|
||||
// "disabled" turns thinking off — the API then routes to K2.6.
|
||||
// kimi-for-coding (K2.7 Code) always thinks and has no knob.
|
||||
if id.starts_with("k3") {
|
||||
Some(ReasoningMode::ValueSet {
|
||||
values: ["disabled", "low", "high", "max"]
|
||||
.iter().map(|s| s.to_string()).collect(),
|
||||
default: Some("max".to_string()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_request(&self, value: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
// The Kimi Code API accepts a flat `reasoning_effort`: "none" disables
|
||||
// thinking, low/high/max select the effort (unknown values → HTTP 400).
|
||||
match value.as_str()? {
|
||||
"disabled" => Some(serde_json::json!({ "reasoning_effort": "none" })),
|
||||
effort => Some(serde_json::json!({ "reasoning_effort": effort })),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some(build_openai_llm(self, Self::BASE_URL, record, model, false))
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta {
|
||||
ProviderUiMeta {
|
||||
type_id: "moonshot_code",
|
||||
display_name: "Moonshot AI Kimi Code",
|
||||
description: Some("Kimi Code subscription models — k3 / kimi-for-coding (OpenAI-compatible)"),
|
||||
color: "#000000",
|
||||
icon: "bi-code-slash",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ impl ApiProvider for OllamaProvider {
|
||||
description: Some("Local models via Ollama"),
|
||||
color: "#f97316",
|
||||
icon: "bi-terminal",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "base_url", label: "Base URL", required: false, secret: false },
|
||||
],
|
||||
|
||||
@@ -2,9 +2,8 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm};
|
||||
use crate::transcribe::TranscribeModelRecord;
|
||||
use crate::transcribe::openai_audio::OpenAiAudioTranscriber;
|
||||
use crate::tts::TtsModelRecord;
|
||||
@@ -47,15 +46,7 @@ impl ApiProvider for OpenAiProvider {
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some((|| {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for open_ai", record.name))?;
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new("https://api.openai.com/v1", key, extra, false)),
|
||||
prompt_cache: false,
|
||||
})
|
||||
})())
|
||||
Some(build_openai_llm(self, "https://api.openai.com/v1", record, model, false))
|
||||
}
|
||||
|
||||
fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option<Result<Arc<dyn crate::tts::TextToSpeech>>> {
|
||||
@@ -90,6 +81,7 @@ impl ApiProvider for OpenAiProvider {
|
||||
description: None,
|
||||
color: "#10a37f",
|
||||
icon: "bi-lightning-charge",
|
||||
lists_models: false,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
ProviderField { key: "base_url", label: "Base URL (optional)", required: false, secret: false },
|
||||
|
||||
@@ -2,11 +2,10 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use crate::image_generate::ImageGenerateModelRecord;
|
||||
use crate::image_generate::openrouter_image::OpenRouterImageGenerator;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm, fetch_openai_models};
|
||||
use crate::transcribe::TranscribeModelRecord;
|
||||
use crate::transcribe::openai_audio::OpenAiAudioTranscriber;
|
||||
use crate::tts::TtsModelRecord;
|
||||
@@ -48,19 +47,9 @@ impl OpenRouterProvider {
|
||||
}
|
||||
|
||||
async fn fetch_catalog(&self, api_key: &str) -> Result<Vec<RemoteLlmModelInfo>> {
|
||||
let resp: serde_json::Value = self.http
|
||||
.get("https://openrouter.ai/api/v1/models")
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("OpenRouter request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("OpenRouter response parse failed: {e}"))?;
|
||||
let raw = fetch_openai_models(&self.http, "https://openrouter.ai/api/v1", Some(api_key), "OpenRouter").await?;
|
||||
|
||||
let models = resp["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("unexpected OpenRouter response shape"))?
|
||||
let models = raw
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?.to_string();
|
||||
@@ -150,17 +139,9 @@ impl ApiProvider for OpenRouterProvider {
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some((|| {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for openrouter", record.name))?;
|
||||
// Anthropic prompt-caching only works for models served by Anthropic on OpenRouter.
|
||||
let prompt_cache = model.model_id.starts_with("anthropic/");
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new("https://openrouter.ai/api/v1", key, extra, prompt_cache)),
|
||||
prompt_cache,
|
||||
})
|
||||
})())
|
||||
// Anthropic prompt-caching only works for models served by Anthropic on OpenRouter.
|
||||
let prompt_cache = model.model_id.starts_with("anthropic/");
|
||||
Some(build_openai_llm(self, "https://openrouter.ai/api/v1", record, model, prompt_cache))
|
||||
}
|
||||
|
||||
fn build_tts(&self, record: &LlmProviderRecord, model: &TtsModelRecord) -> Option<Result<Arc<dyn crate::tts::TextToSpeech>>> {
|
||||
@@ -207,6 +188,7 @@ impl ApiProvider for OpenRouterProvider {
|
||||
description: None,
|
||||
color: "#8b5cf6",
|
||||
icon: "bi-hdd-stack",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, build_openai_llm};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
|
||||
|
||||
/// Z.AI (Zhipu AI) — OpenAI-compatible GLM API.
|
||||
@@ -119,15 +116,7 @@ impl ApiProvider for ZaiProvider {
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some((|| {
|
||||
let key = record.api_key.as_deref()
|
||||
.with_context(|| format!("provider '{}': api_key required for zai", record.name))?;
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new(Self::BASE_URL, key, extra, false)),
|
||||
prompt_cache: false,
|
||||
})
|
||||
})())
|
||||
Some(build_openai_llm(self, Self::BASE_URL, record, model, false))
|
||||
}
|
||||
|
||||
fn ui_meta(&self) -> ProviderUiMeta {
|
||||
@@ -137,6 +126,7 @@ impl ApiProvider for ZaiProvider {
|
||||
description: Some("Zhipu AI GLM models (OpenAI-compatible)"),
|
||||
color: "#4f46e5",
|
||||
icon: "bi-stars",
|
||||
lists_models: true,
|
||||
fields: &[
|
||||
ProviderField { key: "api_key", label: "API Key", required: true, secret: true },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user