AnyBot/src/providers.rs

1548 lines
74 KiB
Rust
Raw Normal View History

// The switchable AI providers and how to talk to each one.
// Every provider streams its reply piece by piece over Server-Sent Events.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::Duration;
use crate::model::{Message, Role, ToolCall, ToolStatus};
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)]
pub enum ProviderKind {
Xai,
OpenAi,
Anthropic,
Gemini,
OpenRouter,
OpenCodeGo,
Ollama,
Custom,
}
pub const ALL_PROVIDERS: [ProviderKind; 8] = [
ProviderKind::Xai,
ProviderKind::OpenAi,
ProviderKind::Anthropic,
ProviderKind::Gemini,
ProviderKind::OpenRouter,
ProviderKind::OpenCodeGo,
ProviderKind::Ollama,
ProviderKind::Custom,
];
/// How long to wait for the first bytes before giving up
/// (the original shows "模型未能及時開始回應" in that case; its deadline is server-side: estimated).
pub const FIRST_RESPONSE_TIMEOUT_SECS: u64 = 120;
/// Connect deadline: main-core.cjs @1082927, `$D=15e3` (gateway-sse-connect).
pub const CONNECT_TIMEOUT_MS: u64 = 15_000;
/// Mid-stream silence that counts as a stall: main-core.cjs @1082927, `VD=35e3` (gateway-sse-stall idleMs).
pub const STREAM_IDLE_STALL_MS: u64 = 35_000;
/// Retry policy: main-core.cjs @1082927, `D3={maxAttempts:3,initialDelayMs:1e3,maxDelayMs:4e3,backoffFactor:2}`.
pub const RETRY_MAX_ATTEMPTS: u32 = 3;
pub const RETRY_INITIAL_DELAY_MS: u64 = 1_000;
pub const RETRY_MAX_DELAY_MS: u64 = 4_000;
pub const RETRY_BACKOFF_FACTOR: u64 = 2;
/// Longest server-paced Retry-After we honour (SAND-E0408 backendCapacityDeferred): estimated cap.
pub const RETRY_AFTER_CAP_MS: u64 = 30_000;
pub const MAX_OUTPUT_TOKENS_ANTHROPIC: u64 = 64000;
pub const ANTHROPIC_VERSION: &str = "2026-10-21";
/// OpenCode Go (opencode.ai/docs/go): one subscription, many open models. Each model is served
/// in its own API style — the public models list says which (`chat-completions` → {base}/openai/v1,
/// `messages` → {base}/anthropic/v1); every request carries a stable `x-opencode-session`.
pub const OPENCODE_GO_BASE: &str = "https://opencode.ai/inference/go";
impl ProviderKind {
pub fn key(self) -> &'static str {
match self {
ProviderKind::Xai => "xai",
ProviderKind::OpenAi => "openai",
ProviderKind::Anthropic => "anthropic",
ProviderKind::Gemini => "gemini",
ProviderKind::OpenRouter => "openrouter",
ProviderKind::OpenCodeGo => "opencode-go",
ProviderKind::Ollama => "ollama",
ProviderKind::Custom => "custom",
}
}
pub fn label(self) -> &'static str {
match self {
ProviderKind::Xai => "xAI Grok",
ProviderKind::OpenAi => "OpenAI",
ProviderKind::Anthropic => "Claude",
ProviderKind::Gemini => "Google Gemini",
ProviderKind::OpenRouter => "OpenRouter",
ProviderKind::OpenCodeGo => "OpenCode Go",
ProviderKind::Ollama => "Ollama(本機)",
ProviderKind::Custom => "自訂(OpenAI 相容)",
}
}
pub fn default_base_url(self) -> &'static str {
match self {
ProviderKind::Xai => "https://api.x.ai/v1",
ProviderKind::OpenAi => "https://api.openai.com/v1",
ProviderKind::Anthropic => "https://api.anthropic.com/v1",
ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai",
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
// The models list is {base}/v1/models; chat goes to {base}/openai/v1 or {base}/anthropic/v1.
ProviderKind::OpenCodeGo => OPENCODE_GO_BASE,
ProviderKind::Ollama => "http://localhost:11434/v1",
ProviderKind::Custom => "http://localhost:8080/v1",
}
}
pub fn suggested_model(self) -> &'static str {
match self {
ProviderKind::Xai => "grok-4",
ProviderKind::OpenAi => "gpt-5",
ProviderKind::Anthropic => "claude-opus-5-5",
ProviderKind::Gemini => "gemini-2.5-flash",
ProviderKind::OpenRouter => "openrouter/auto",
ProviderKind::OpenCodeGo => "kimi-k3",
ProviderKind::Ollama => "llama3.2",
ProviderKind::Custom => "local-model",
}
}
/// A few names to pick from before the real list is fetched.
pub fn starter_models(self) -> &'static [&'static str] {
match self {
ProviderKind::Xai => &["grok-4", "grok-4-fast", "grok-3-mini"],
ProviderKind::OpenAi => &["gpt-5", "gpt-5-mini", "gpt-4.1"],
ProviderKind::Anthropic => &["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"],
ProviderKind::Gemini => &["gemini-2.5-flash", "gemini-2.5-pro"],
ProviderKind::OpenRouter => &["openrouter/auto"],
ProviderKind::OpenCodeGo => &["kimi-k3", "glm-5.3", "deepseek-v4-pro", "qwen3.8-max", "minimax-m3", "mimo-v2.6-pro"],
ProviderKind::Ollama => &["llama3.2", "qwen2.5", "gemma3"],
ProviderKind::Custom => &[],
}
}
pub fn needs_key(self) -> bool {
!matches!(self, ProviderKind::Ollama | ProviderKind::Custom)
}
pub fn env_var(self) -> Option<&'static str> {
match self {
ProviderKind::Xai => Some("XAI_API_KEY"),
ProviderKind::OpenAi => Some("OPENAI_API_KEY"),
ProviderKind::Anthropic => Some("ANTHROPIC_API_KEY"),
ProviderKind::Gemini => Some("GEMINI_API_KEY"),
ProviderKind::OpenRouter => Some("OPENROUTER_API_KEY"),
ProviderKind::OpenCodeGo => Some("OPENCODE_API_KEY"),
_ => None,
}
}
pub fn key_help(self) -> &'static str {
match self {
ProviderKind::Xai => "在 console.x.ai 取得 API 金鑰",
ProviderKind::OpenAi => "在 platform.openai.com 取得 API 金鑰",
ProviderKind::Anthropic => "在 console.anthropic.com 取得 API 金鑰",
ProviderKind::Gemini => "在 aistudio.google.com 取得 API 金鑰",
ProviderKind::OpenRouter => "在 openrouter.ai 取得 API 金鑰",
ProviderKind::OpenCodeGo => "在 opencode.ai 的 Console 訂閱 Go,再複製 API 金鑰",
ProviderKind::Ollama => "先安裝並開啟 Ollama,不需要金鑰",
ProviderKind::Custom => "任何 OpenAI 相容的服務(LM Studio、vLLM…)",
}
}
}
/// Everything one model call needs, copied so the worker thread owns it.
#[derive(Clone)]
pub struct ChatRequest {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: String,
pub model: String,
pub system: String,
pub history: Vec<Message>,
/// Tool specs ({name, description, parameters}); empty for plain chat.
pub tools: Vec<Value>,
/// One id per conversation (OpenCode Go routes and caches by it).
pub session: String,
}
pub enum StreamEvent {
Text(String),
Thinking(String),
/// Any line from the provider (keeps the stall watchdog quiet).
Alive,
/// A retry is about to happen; the text says why.
Retrying(String),
/// The model asked for a tool; shown at once, run after the step ends.
ToolCall(ToolCall),
/// A tool's state changed (waiting for approval, running, done…).
ToolStatus(String, ToolStatus),
/// Ask the person before running a tool; answer on the sender.
NeedApproval(Approval),
/// One model step finished: replace the streaming reply with the full message.
StepDone(Message),
/// A tool result to append to the chat.
ToolResult(Message),
/// The next model step starts: add a fresh reply bubble.
NextStep(String),
/// The oldest `n` messages were folded into this summary.
/// Older messages were folded into this summary; `cut` is the count in the model's view and
/// `boundary` the timestamp of the first kept message (the window's own copy may be longer).
Compacted(usize, Message, i64),
/// A message the person sent mid-turn was handed to the model (add it to the chat).
Steered(Message),
/// Do something in the app (routines, bots, settings) and answer on the sender.
AppAction(AppCall),
/// 表情回應: the bot put an emoji on the person's latest message.
React(String),
/// The bot sent a file into the chat (a file card under its reply).
Attach(crate::attach::Attachment),
Done,
Error(String),
}
pub struct Approval {
pub call_id: String,
pub tool: String,
pub reason: String,
/// What "always allow" would remember; empty when it can't be remembered.
pub grant: String,
pub reply: Sender<ApprovalAnswer>,
pub kind: ApprovalKind,
}
/// What the person is asked.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ApprovalKind {
/// One action (run this command, edit this file…).
#[default]
Action,
/// This bot's computer tools are off: let it use them? ("always" turns them on for the bot.)
EnableTools,
/// 在這台電腦上執行 is off: let it work on this Mac? ("always" turns the setting on.)
EnableThisComputer,
}
pub struct AppCall {
pub call_id: String,
pub name: String,
pub args: String,
/// (result text, ok)
pub reply: Sender<(String, bool)>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ApprovalAnswer {
Once,
Always(String),
Reject,
}
/// What one model call produced.
#[derive(Default, Debug)]
pub struct StepOutput {
pub text: String,
pub thinking: String,
pub tool_calls: Vec<ToolCall>,
/// Claude's raw content blocks, kept to replay thinking signatures unchanged.
pub blocks: Vec<Value>,
}
/// Why a request failed, sorted the way the original's error catalog sorts them
/// (main-core.cjs @689060..692409, SAND-E0401..E0418).
pub struct Failure {
pub msg: String,
pub retryable: bool,
pub retry_after_ms: Option<u64>,
}
impl Failure {
fn retry(msg: String) -> Self {
Failure { msg, retryable: true, retry_after_ms: None }
}
fn fatal(msg: String) -> Self {
Failure { msg, retryable: false, retry_after_ms: None }
}
}
/// Delay before retry `n` (1-based): 1 s, 2 s, 4 s, capped at 4 s.
pub fn backoff_delay_ms(retry: u32) -> u64 {
(RETRY_INITIAL_DELAY_MS * RETRY_BACKOFF_FACTOR.pow(retry.saturating_sub(1))).min(RETRY_MAX_DELAY_MS)
}
fn agent() -> ureq::Agent {
let config = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_connect(Some(Duration::from_millis(CONNECT_TIMEOUT_MS)))
.timeout_recv_response(Some(Duration::from_secs(FIRST_RESPONSE_TIMEOUT_SECS)))
// A stream that goes silent (a NAT that dropped the mapping, a server that died without
// a FIN) must end: without a read timeout the lines() loop blocks forever, and every
// such turn leaks a thread and its socket. 5 minutes of total silence is a dead stream.
.timeout_per_read(Some(Duration::from_secs(300)))
.build();
config.into()
}
pub fn resolve_key(kind: ProviderKind, stored: &str) -> String {
if !stored.trim().is_empty() {
return stored.trim().to_string();
}
kind.env_var().and_then(|v| std::env::var(v).ok()).unwrap_or_default()
}
pub fn resolve_base(kind: ProviderKind, stored: &str) -> String {
let b = if stored.trim().is_empty() { kind.default_base_url() } else { stored.trim() };
b.trim_end_matches('/').to_string()
}
/// Claude models that take adaptive thinking (older ones reject it).
fn claude_adaptive(model: &str) -> bool {
["claude-opus-5", "claude-sonnet-5", "claude-haiku-5", "claude-fable-5", "claude-mythos-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-4-6"]
.iter()
.any(|p| model.starts_with(p))
}
/// Claude models where the server can retry a declined request on another model.
fn claude_fallbacks(model: &str) -> bool {
["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5"].contains(&model)
}
fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
let detail = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| {
v.pointer("/error/message")
.or_else(|| v.pointer("/message"))
.or_else(|| v.get("error"))
.map(|m| m.as_str().map(str::to_string).unwrap_or_else(|| m.to_string()))
})
.unwrap_or_else(|| body.chars().take(300).collect());
let hint = match status {
401 | 403 => "金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。",
404 => "找不到這個模型或網址,請檢查模型名稱。",
429 => "模型供應商已超載或用量已達上限。通常是暫時的,請稍後再試。",
500..=599 => "模型供應商目前負載過高。通常是暫時的。",
_ => "",
};
let lower = detail.to_lowercase();
// SAND-E0404 contextWindowOverflow: a retry would repeat it.
if (status == 400 || status == 413) && ["context", "too long", "too many tokens", "maximum context", "prompt is too long"].iter().any(|k| lower.contains(k)) {
return Failure::fatal(format!("對話太長了,超過這個模型能讀的長度。請清除聊天記錄或換一個模型。\n({detail})"));
}
if status == 400 && lower.contains("tool") && (lower.contains("support") || lower.contains("not available")) {
return Failure::fatal(format!("這個模型不支援工具。請換一個支援工具的模型,或在 Bot 設定裡關掉「電腦操作」。\n({detail})"));
}
let msg = format!("錯誤 {status}:{detail}\n{hint}").trim().to_string();
// SAND-E0401 providerOverloaded / SAND-E0408 backendCapacityDeferred: retry.
if status == 429 || status == 408 || status == 529 || status >= 500 {
Failure { msg, retryable: true, retry_after_ms: retry_after }
} else {
Failure::fatal(msg)
}
}
fn retry_after_ms(resp: &ureq::http::Response<ureq::Body>) -> Option<u64> {
let v = resp.headers().get("retry-after")?.to_str().ok()?;
v.trim().parse::<f64>().ok().map(|s| (s * 1000.0) as u64).map(|ms| ms.min(RETRY_AFTER_CAP_MS))
}
/// One model call with the original's retry rules: retry overloads, resets and unreachable
/// backends up to 3 tries (1 s, 2 s, 4 s or Retry-After), but never once output reached the screen.
pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result<StepOutput, String> {
// The bot's instructions and tool descriptions in this system's words ("this computer" on Linux).
#[cfg(not(target_os = "macos"))]
let localized = {
let mut r = req.clone();
r.system = crate::platform::localize(&r.system);
r.tools.iter_mut().for_each(crate::platform::localize_value);
r
};
#[cfg(not(target_os = "macos"))]
let req = &localized;
if req.kind.needs_key() && req.api_key.is_empty() {
return Err(format!("還沒有設定 {} 的 API 金鑰。請到「設定 → AI 供應商」貼上金鑰。", req.kind.label()));
}
let produced = std::cell::Cell::new(false);
let tracked = |ev: StreamEvent| {
if matches!(ev, StreamEvent::Text(_) | StreamEvent::Thinking(_) | StreamEvent::ToolCall(_)) {
produced.set(true);
}
send(ev);
};
let mut attempt = 1;
// Steps left without a result (stopped while waiting for the person) are closed off.
let repaired = ChatRequest { history: repair_history(&req.history), ..req.clone() };
let req = &repaired;
let mut blind_copy: Option<ChatRequest> = None;
loop {
// A model that can't see pictures gets the request without them: the person's pictures
// and the newest screenshot described by a model that can (看圖小幫手).
if !sees_images(req) && blind_copy.is_none() {
blind_copy = Some(if DESCRIBING.with(|d| d.get()) { without_images(req) } else { with_descriptions(req, cancel, send) });
}
let req = blind_copy.as_ref().unwrap_or(req);
let result = match req.kind {
ProviderKind::Anthropic => stream_anthropic(req, cancel, &tracked),
ProviderKind::OpenCodeGo => {
let mut r = req.clone();
if opencode_go_style(&req.base_url, &req.model) == "messages" {
r.base_url = format!("{}/anthropic/v1", req.base_url);
stream_anthropic(&r, cancel, &tracked)
} else {
r.base_url = format!("{}/openai/v1", req.base_url);
stream_openai_compatible(&r, cancel, &tracked)
}
}
_ => stream_openai_compatible(req, cancel, &tracked),
};
match result {
Ok(out) => return Ok(out),
Err(f) if image_refused(&f.msg) && sees_images(req) && !produced.get() && !cancel.load(Ordering::Relaxed) => {
mark_blind(req);
send(StreamEvent::Retrying("這個模型不能看圖片,改成不附圖片再試一次…".into()));
}
Err(f) if f.retryable && !produced.get() && attempt < RETRY_MAX_ATTEMPTS && !cancel.load(Ordering::Relaxed) => {
let wait = f.retry_after_ms.unwrap_or_else(|| backoff_delay_ms(attempt));
send(StreamEvent::Retrying(format!("{}\n{:.0} 秒後重試(第 {} 次,共 {} 次)…", f.msg, wait as f64 / 1000.0, attempt + 1, RETRY_MAX_ATTEMPTS)));
let until = std::time::Instant::now() + Duration::from_millis(wait);
while std::time::Instant::now() < until {
if cancel.load(Ordering::Relaxed) {
return Ok(StepOutput::default());
}
std::thread::sleep(Duration::from_millis(100));
}
attempt += 1;
}
Err(f) => return Err(f.msg),
}
}
}
fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
read_sse_from(body.into_reader(), cancel, send, on_data)
}
fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> {
let reader = BufReader::new(r);
let mut event = String::new();
for line in reader.lines() {
if cancel.load(Ordering::Relaxed) {
return Ok(());
}
// SAND-E0403 streamReset: retryable.
let line = line.map_err(|e| Failure::retry(format!("連線中斷:{e}")))?;
send(StreamEvent::Alive);
if let Some(ev) = line.strip_prefix("event:") {
event = ev.trim().to_string();
} else if let Some(data) = line.strip_prefix("data:") {
if !on_data(&event, data.trim()) {
return Ok(());
}
} else if line.is_empty() {
event.clear();
}
}
Ok(())
}
/// Summaries become part of the system prompt; everything else stays in order.
fn system_with_summaries(req: &ChatRequest) -> String {
let mut s = req.system.clone();
for m in req.history.iter().filter(|m| m.summary) {
s.push_str("\n\n先前對話的摘要:\n");
s.push_str(&m.text);
}
s
}
/// Which messages still show their screenshots: the newest `KEEP_SCREENSHOTS`.
fn kept_images(history: &[Message]) -> Vec<usize> {
let mut idx: Vec<usize> = history.iter().enumerate().filter(|(_, m)| !m.images.is_empty() && !m.summary && m.role == Role::Tool).map(|(i, _)| i).collect();
let drop = idx.len().saturating_sub(crate::computer::KEEP_SCREENSHOTS);
idx.drain(..drop);
idx
}
const OLD_SCREENSHOT_NOTE: &str = "(較早的截圖已省略)";
/// Said in place of a screenshot when the model can't take pictures.
const BLIND_SCREENSHOT_NOTE: &str = "(截圖已拍好,但這個模型看不到圖片,所以沒有附上。請改用文字方式確認畫面,例如讀取視窗或網頁的文字。)";
/// What a helper is asked about a screenshot.
const SCREEN_ASK: &str = "這是一台電腦螢幕的截圖。請描述:開著哪些視窗或 App、畫面上的文字(照抄重要的字)、按鈕和欄位,以及它們大約的位置(以這張圖的像素座標,左上角是 0,0)。不要猜看不清楚的字。";
const BLIND_ATTACHMENT_NOTE: &str = "(使用者附了圖片,但這個模型看不到圖片。)";
/// Models (base url + name) that refused a picture: later requests leave pictures out.
static NO_VISION: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
fn vision_key(req: &ChatRequest) -> String {
format!("{}|{}", req.base_url, req.model)
}
pub fn sees_images(req: &ChatRequest) -> bool {
!NO_VISION.lock().unwrap().as_ref().map(|s| s.contains(&vision_key(req))).unwrap_or(false)
}
fn mark_blind(req: &ChatRequest) {
NO_VISION.lock().unwrap().get_or_insert_with(Default::default).insert(vision_key(req));
}
/// The provider said this model can't take images ("At most 0 image(s)…", "does not support
/// image input", "unknown variant `image_url`"…).
pub fn image_refused(msg: &str) -> bool {
let l = msg.to_lowercase();
let about_images = l.contains("image") || l.contains("vision") || l.contains("multimodal") || l.contains("圖片") || l.contains("图片");
about_images && ["at most 0", "not support", "unsupported", "doesn't support", "does not support", "no vision", "not a vision", "not multimodal", "unknown variant", "invalid type", "only text", "text-only", "不支援", "不支持"].iter().any(|k| l.contains(k))
}
/// Every tool call needs its result right after it (providers refuse the conversation
/// otherwise). A step stopped or refused while it waited for the person has none: give it one.
/// Results whose call is gone are dropped.
pub fn repair_history(history: &[Message]) -> Vec<Message> {
let mut out: Vec<Message> = Vec::with_capacity(history.len());
let mut i = 0;
while i < history.len() {
let m = &history[i];
if m.role == Role::Tool {
// A result with no call right before it.
i += 1;
continue;
}
out.push(m.clone());
i += 1;
if m.role == Role::Assistant && !m.tool_calls.is_empty() {
let ids: Vec<&str> = m.tool_calls.iter().map(|c| c.id.as_str()).collect();
let mut seen = vec![];
while i < history.len() && history[i].role == Role::Tool {
let r = &history[i];
if ids.contains(&r.tool_call_id.as_str()) && !seen.contains(&r.tool_call_id) {
seen.push(r.tool_call_id.clone());
out.push(r.clone());
}
i += 1;
}
for c in &m.tool_calls {
if !seen.contains(&c.id) {
let why = if c.status == ToolStatus::Denied { "(使用者沒有允許這個步驟,所以沒有執行。)" } else { "(這個步驟被中止了,沒有執行完。)" };
out.push(Message::tool_result(&c.id, &c.name, why));
}
}
}
}
out
}
// ---------- 看圖小幫手 ----------
/// A model of the person's that sees pictures (another provider, or another model).
#[derive(Clone, Debug, PartialEq)]
pub struct Eyes {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: String,
pub model: String,
}
static EYES: std::sync::Mutex<Vec<Eyes>> = std::sync::Mutex::new(Vec::new());
thread_local! {
/// Inside a helper's own call (it never asks a helper itself).
static DESCRIBING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The models to ask, best first (the app sets this from the person's providers).
pub fn set_eyes(v: Vec<Eyes>) {
*EYES.lock().unwrap() = v;
}
/// The helper list, the description cache and the failure list are process-wide: tests that
/// touch them must run one at a time, from a clean slate, or they undo each other (the
/// describe cache hit in one test made another's helper never get called).
#[cfg(test)]
pub fn vision_test_guard() -> std::sync::MutexGuard<'static, ()> {
static L: std::sync::Mutex<()> = std::sync::Mutex::new(());
let g = L.lock().unwrap_or_else(|e| e.into_inner());
notes().lock().unwrap().clear();
failed_eyes().lock().unwrap().clear();
set_eyes(vec![]);
g
}
/// Models likely to see pictures, by provider.
pub fn seeing_models(kind: ProviderKind) -> &'static [&'static str] {
match kind {
ProviderKind::Xai => &["grok-4", "grok-4-fast"],
ProviderKind::Anthropic => &["claude-sonnet-5-5", "claude-haiku-5-5"],
ProviderKind::OpenAi => &["gpt-5-mini", "gpt-4.1"],
ProviderKind::Gemini => &["gemini-2.5-flash"],
ProviderKind::OpenRouter => &["openrouter/auto"],
_ => &[],
}
}
const EYES_PROMPT: &str = "你是「看圖小幫手」:替一個看不到圖片的 AI 助理看圖。用繁體中文把圖片完整描述出來,讓它不用看圖也能幫使用者:畫面是什麼、主要的東西和位置、所有看得到的文字(原文照抄,包括數字、表格、按鈕和錯誤訊息)、顏色或狀態等重點。只描述,不評論、不問問題、不說你是誰。";
/// Descriptions already made (by the picture), kept across restarts.
fn notes_path() -> std::path::PathBuf {
crate::model::State::path().with_file_name("image-notes.json")
}
fn notes() -> &'static std::sync::Mutex<std::collections::HashMap<String, (String, String)>> {
static N: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, (String, String)>>> = std::sync::OnceLock::new();
N.get_or_init(|| std::sync::Mutex::new(if cfg!(test) { Default::default() } else { std::fs::read(notes_path()).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() }))
}
/// Helpers that just failed (wrong model name, no credit, down…), skipped for a while.
fn failed_eyes() -> &'static std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>> {
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> = std::sync::OnceLock::new();
F.get_or_init(Default::default)
}
fn picture_key(b64: &str) -> String {
let d = ring::digest::digest(&ring::digest::SHA256, b64.as_bytes());
d.as_ref()[..12].iter().map(|b| format!("{b:02x}")).collect()
}
/// Ask the first helper that can see pictures to describe this one: (description, model).
fn describe(picture: &str, ask: &str, not: &ChatRequest, cancel: &AtomicBool) -> Option<(String, String)> {
let key = picture_key(picture);
if let Some(n) = notes().lock().unwrap().get(&key) {
return Some(n.clone());
}
let helpers: Vec<Eyes> = EYES.lock().unwrap().clone();
for e in helpers {
let req = ChatRequest {
kind: e.kind,
base_url: e.base_url.clone(),
api_key: e.api_key.clone(),
model: e.model.clone(),
system: EYES_PROMPT.into(),
history: vec![Message { images: vec![picture.to_string()], ..Message::user(ask) }],
tools: vec![],
session: String::new(),
};
let id = vision_key(&req);
let resting = failed_eyes().lock().unwrap().get(&id).map(|t| t.elapsed().as_secs() < 600).unwrap_or(false);
if (req.base_url == not.base_url && req.model == not.model) || !sees_images(&req) || resting || cancel.load(Ordering::Relaxed) {
continue;
}
DESCRIBING.with(|d| d.set(true));
let out = run_step(&req, cancel, &|_| {});
DESCRIBING.with(|d| d.set(false));
match out {
// It may have turned out not to see pictures after all (and answered without).
Ok(o) if sees_images(&req) && !o.text.trim().is_empty() => {
let note = (o.text.trim().to_string(), e.model.clone());
let mut all = notes().lock().unwrap();
if all.len() > 500 {
all.clear();
}
all.insert(key, note.clone());
if let (false, Ok(b)) = (cfg!(test), serde_json::to_vec(&*all)) {
let _ = std::fs::write(notes_path(), b);
}
return Some(note);
}
_ => {
failed_eyes().lock().unwrap().insert(id, std::time::Instant::now());
}
}
}
None
}
/// The request for a model that can't see pictures: each of the person's pictures replaced by
/// what a helper saw in it; screenshots by a short note.
fn with_descriptions(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> ChatRequest {
let mut r = req.clone();
let mut told = false;
// The newest screenshot is described too (what the bot needs to act next); older ones stay a note.
let newest_shot = r.history.iter().rposition(|m| m.role == Role::Tool && !m.images.is_empty());
for (i, m) in r.history.iter_mut().enumerate().filter(|(_, m)| !m.images.is_empty()) {
if m.role == Role::Tool {
let pictures = std::mem::take(&mut m.images);
let seen = if Some(i) == newest_shot {
if !told && !notes().lock().unwrap().contains_key(&picture_key(&pictures[0])) {
send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型看這張截圖…".into()));
told = true;
}
describe(&pictures[0], SCREEN_ASK, req, cancel)
} else {
None
};
let add = match seen {
Some((d, model)) => format!("(截圖已拍好;你看不到圖片,以下是「{model}」看過這張截圖後的描述)\n{d}"),
None => BLIND_SCREENSHOT_NOTE.to_string(),
};
m.text = if m.text.is_empty() { add } else { format!("{}\n{add}", m.text) };
continue;
}
let pictures = std::mem::take(&mut m.images);
let ask = format!("請描述這張圖片。使用者附上它時說:「{}」", m.text.chars().take(300).collect::<String>());
let mut added = String::new();
for (i, p) in pictures.iter().enumerate() {
if !told && !notes().lock().unwrap().contains_key(&picture_key(p)) {
send(StreamEvent::Retrying("這個模型看不到圖片,正在請另一個看得懂圖的模型先看一遍…".into()));
told = true;
}
let which = if pictures.len() > 1 { format!("第 {} 張圖片", i + 1) } else { "這張圖片".to_string() };
added.push_str(&match describe(p, &ask, req, cancel) {
Some((d, model)) => format!("\n\n(你看不到圖片;以下是「{model}」看過{which}後的描述)\n{d}"),
None => format!("\n\n{BLIND_ATTACHMENT_NOTE}(帳號裡也沒有看得懂圖片的模型可以代看。可以請使用者到「設定 › 模型」加一個,例如 Grok、Claude、GPT 或 Gemini。)"),
});
}
m.text.push_str(&added);
}
r
}
/// The same request with pictures replaced by a short note.
fn without_images(req: &ChatRequest) -> ChatRequest {
let mut r = req.clone();
for m in r.history.iter_mut().filter(|m| !m.images.is_empty()) {
m.images.clear();
let note = if m.role == Role::Tool { BLIND_SCREENSHOT_NOTE } else { BLIND_ATTACHMENT_NOTE };
m.text = if m.text.is_empty() { note.to_string() } else { format!("{}\n{note}", m.text) };
}
r
}
pub fn openai_messages(req: &ChatRequest) -> Vec<Value> {
let mut messages = vec![];
let system = system_with_summaries(req);
if !system.trim().is_empty() {
messages.push(json!({"role": "system", "content": system}));
}
let kept = kept_images(&req.history);
// Tool messages carry text only; their screenshots follow as one user message after the run
// of tool results (a user message between tool results would break the call/answer pairs).
let mut pending: Vec<Value> = vec![];
let flush = |messages: &mut Vec<Value>, pending: &mut Vec<Value>| {
if !pending.is_empty() {
let mut parts = vec![json!({"type": "text", "text": "(上面工具的螢幕截圖)"})];
parts.append(pending);
messages.push(json!({"role": "user", "content": parts}));
}
};
for (i, m) in req.history.iter().enumerate().filter(|(_, m)| !m.summary) {
if m.role != Role::Tool {
flush(&mut messages, &mut pending);
}
match m.role {
Role::User if !m.images.is_empty() => {
// Attached pictures go with the person's words.
let mut parts = vec![json!({"type": "text", "text": if m.text.is_empty() { "(附件)".to_string() } else { m.text.clone() }})];
parts.extend(m.images.iter().map(|b| json!({"type": "image_url", "image_url": {"url": format!("data:image/jpeg;base64,{b}")}})));
messages.push(json!({"role": "user", "content": parts}));
}
Role::User => {
if !m.text.is_empty() {
messages.push(json!({"role": "user", "content": m.text}));
}
}
Role::Assistant => {
if m.error && m.tool_calls.is_empty() {
continue;
}
if m.tool_calls.is_empty() {
if !m.text.is_empty() {
messages.push(json!({"role": "assistant", "content": m.text}));
}
} else {
let calls: Vec<Value> = m.tool_calls.iter().map(|c| json!({"id": c.id, "type": "function", "function": {"name": c.name, "arguments": if c.args.trim().is_empty() { "{}" } else { &c.args }}})).collect();
messages.push(json!({"role": "assistant", "content": if m.text.is_empty() { Value::Null } else { json!(m.text) }, "tool_calls": calls}));
}
}
Role::Tool => {
let shown = kept.contains(&i);
let text = if !m.images.is_empty() && !shown { format!("{}\n{OLD_SCREENSHOT_NOTE}", m.text) } else { m.text.clone() };
messages.push(json!({"role": "tool", "tool_call_id": m.tool_call_id, "content": text}));
if shown {
pending.extend(m.images.iter().map(|b| json!({"type": "image_url", "image_url": {"url": format!("data:image/jpeg;base64,{b}")}})));
}
}
}
}
flush(&mut messages, &mut pending);
messages
}
fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result<StepOutput, Failure> {
let mut body = json!({"model": req.model, "messages": openai_messages(req), "stream": true});
if !req.tools.is_empty() {
body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::<Vec<_>>());
}
let url = format!("{}/chat/completions", req.base_url);
let mut headers: Vec<(String, String)> = vec![];
if !req.api_key.is_empty() {
headers.push(("Authorization".into(), format!("Bearer {}", req.api_key)));
}
if req.kind == ProviderKind::OpenRouter {
headers.push(("X-Title".into(), "AnyBot".into()));
}
if req.kind == ProviderKind::OpenCodeGo {
headers.push(("x-opencode-session".into(), opencode_session(req)));
}
// A brain on a server, a model on the person's own network: through their computer (the
// whole answer comes back at once, so no word-by-word streaming).
let via_hands: Option<Vec<u8>> = match crate::remote::model_hands_for(&url) {
Some(hand) => {
let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?;
if status >= 400 {
return Err(http_error(status, &String::from_utf8_lossy(&bytes), None));
}
Some(bytes)
}
None => None,
};
let resp = match via_hands {
Some(_) => None,
None => {
let mut r = agent().post(&url).header("Content-Type", "application/json");
for (k, v) in &headers {
r = r.header(k, v);
}
// SAND-E0418 turnBackendUnreachable: retryable.
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
let status = resp.status().as_u16();
if status >= 400 {
let ra = retry_after_ms(&resp);
let text = resp.into_body().read_to_string().unwrap_or_default();
return Err(http_error(status, &text, ra));
}
Some(resp)
}
};
let mut out = StepOutput::default();
// Tool calls arrive in pieces keyed by index.
let mut pending: Vec<(String, String, String)> = vec![];
let mut error = None;
let reader: Box<dyn std::io::Read> = match (resp, via_hands) {
(Some(r), _) => Box::new(r.into_body().into_reader()),
(None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())),
};
read_sse_from(reader, cancel, send, |_ev, data| {
if data == "[DONE]" {
return false;
}
if let Ok(v) = serde_json::from_str::<Value>(data) {
if let Some(e) = v.get("error") {
error = Some(e.get("message").and_then(Value::as_str).unwrap_or("未知錯誤").to_string());
return false;
}
let delta = &v["choices"][0]["delta"];
for k in ["reasoning_content", "reasoning"] {
if let Some(t) = delta.get(k).and_then(Value::as_str) {
if !t.is_empty() {
out.thinking.push_str(t);
send(StreamEvent::Thinking(t.to_string()));
}
}
}
if let Some(t) = delta.get("content").and_then(Value::as_str) {
if !t.is_empty() {
out.text.push_str(t);
send(StreamEvent::Text(t.to_string()));
}
}
if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
for c in calls {
let i = match c["index"].as_u64() {
Some(i) => i as usize,
// Some OpenAI-compatible proxies drop the index: a part carrying an
// id or a name starts a new call; an arguments-only part continues the
// last one (starting a fresh slot used to lose the call's tail).
None => {
let starts = c["id"].is_string() || c["function"]["name"].is_string();
if starts || pending.is_empty() {
pending.len()
} else {
pending.len() - 1
}
}
};
while pending.len() <= i {
pending.push(Default::default());
}
if let Some(id) = c["id"].as_str() {
pending[i].0 = id.to_string();
}
if let Some(n) = c["function"]["name"].as_str() {
pending[i].1.push_str(n);
}
if let Some(a) = c["function"]["arguments"].as_str() {
pending[i].2.push_str(a);
}
}
}
}
true
})?;
if let Some(e) = error {
return Err(Failure::fatal(e));
}
for (i, (id, name, args)) in pending.into_iter().enumerate() {
if name.is_empty() {
continue;
}
let id = if id.is_empty() { format!("call_{i}") } else { id };
let call = ToolCall { id, name, args, status: ToolStatus::Waiting };
send(StreamEvent::ToolCall(call.clone()));
out.tool_calls.push(call);
}
Ok(out)
}
pub fn anthropic_messages(req: &ChatRequest) -> Vec<Value> {
let mut messages: Vec<Value> = vec![];
let mut push = |role: &str, blocks: Vec<Value>| {
if blocks.is_empty() {
return;
}
// The API wants user/assistant turns to alternate; merge repeats.
if let Some(last) = messages.last_mut() {
if last["role"] == role {
last["content"].as_array_mut().unwrap().extend(blocks);
return;
}
}
messages.push(json!({"role": role, "content": blocks}));
};
let kept = kept_images(&req.history);
for (i, m) in req.history.iter().enumerate().filter(|(_, m)| !m.summary) {
match m.role {
Role::Tool if !m.images.is_empty() => {
let content = if kept.contains(&i) {
let mut c = vec![json!({"type": "text", "text": m.text})];
c.extend(m.images.iter().map(|b| json!({"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": b}})));
json!(c)
} else {
json!(format!("{}\n{OLD_SCREENSHOT_NOTE}", m.text))
};
push("user", vec![json!({"type": "tool_result", "tool_use_id": m.tool_call_id, "content": content})]);
}
Role::User if !m.images.is_empty() => {
let mut b = vec![json!({"type": "text", "text": if m.text.is_empty() { "(附件)".to_string() } else { m.text.clone() }})];
b.extend(m.images.iter().map(|x| json!({"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": x}})));
push("user", b);
}
Role::User if !m.text.is_empty() => push("user", vec![json!({"type": "text", "text": m.text})]),
Role::User => {}
Role::Assistant => {
if !m.blocks.is_empty() {
push("assistant", m.blocks.clone());
} else if !m.error || !m.tool_calls.is_empty() {
let mut b = vec![];
if !m.text.is_empty() {
b.push(json!({"type": "text", "text": m.text}));
}
for c in &m.tool_calls {
b.push(json!({"type": "tool_use", "id": c.id, "name": c.name, "input": serde_json::from_str::<Value>(&c.args).unwrap_or(json!({}))}));
}
push("assistant", b);
}
}
Role::Tool => push("user", vec![json!({"type": "tool_result", "tool_use_id": m.tool_call_id, "content": m.text})]),
}
}
messages
}
fn stream_anthropic(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Result<StepOutput, Failure> {
let mut body = json!({
"model": req.model,
"max_tokens": MAX_OUTPUT_TOKENS_ANTHROPIC,
"messages": anthropic_messages(req),
"stream": true,
});
let system = system_with_summaries(req);
if !system.trim().is_empty() {
body["system"] = json!(system);
}
if claude_adaptive(&req.model) {
body["thinking"] = json!({"type": "adaptive", "display": "summarized"});
}
if !req.tools.is_empty() {
body["tools"] = json!(req.tools.iter().map(|t| json!({"name": t["name"], "description": t["description"], "input_schema": t["parameters"]})).collect::<Vec<_>>());
}
let mut r = agent()
.post(&format!("{}/messages", req.base_url))
.header("Content-Type", "application/json")
.header("x-api-key", &req.api_key)
.header("anthropic-version", ANTHROPIC_VERSION);
if req.kind == ProviderKind::OpenCodeGo {
// Its Anthropic-style door takes the key as a bearer token too.
r = r.header("Authorization", &format!("Bearer {}", req.api_key)).header("x-opencode-session", &opencode_session(req));
}
if claude_fallbacks(&req.model) {
body["fallbacks"] = json!("default");
r = r.header("anthropic-beta", "server-side-fallback-2026-07-01");
}
// SAND-E0418 turnBackendUnreachable: retryable.
let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?;
let status = resp.status().as_u16();
if status >= 400 {
let ra = retry_after_ms(&resp);
let text = resp.into_body().read_to_string().unwrap_or_default();
return Err(http_error(status, &text, ra));
}
let mut out = StepOutput::default();
let mut blocks: Vec<Value> = vec![];
let mut partial_json: Vec<String> = vec![];
let mut error = None;
read_sse(resp.into_body(), cancel, send, |ev, data| {
let Ok(v) = serde_json::from_str::<Value>(data) else { return true };
let kind = if ev.is_empty() { v["type"].as_str().unwrap_or("") } else { ev };
match kind {
"content_block_start" => {
let i = v["index"].as_u64().unwrap_or(blocks.len() as u64) as usize;
while blocks.len() <= i {
blocks.push(Value::Null);
partial_json.push(String::new());
}
blocks[i] = v["content_block"].clone();
}
"content_block_delta" => {
let i = v["index"].as_u64().unwrap_or(0) as usize;
let d = &v["delta"];
let block = blocks.get_mut(i);
match d["type"].as_str() {
Some("text_delta") => {
let t = d["text"].as_str().unwrap_or("");
out.text.push_str(t);
if let Some(b) = block {
let joined = format!("{}{}", b["text"].as_str().unwrap_or(""), t);
b["text"] = json!(joined);
}
send(StreamEvent::Text(t.to_string()));
}
Some("thinking_delta") => {
let t = d["thinking"].as_str().unwrap_or("");
out.thinking.push_str(t);
if let Some(b) = block {
let joined = format!("{}{}", b["thinking"].as_str().unwrap_or(""), t);
b["thinking"] = json!(joined);
}
send(StreamEvent::Thinking(t.to_string()));
}
Some("signature_delta") => {
if let Some(b) = block {
let joined = format!("{}{}", b["signature"].as_str().unwrap_or(""), d["signature"].as_str().unwrap_or(""));
b["signature"] = json!(joined);
}
}
Some("input_json_delta") => {
if let Some(p) = partial_json.get_mut(i) {
p.push_str(d["partial_json"].as_str().unwrap_or(""));
}
}
_ => {}
}
}
"content_block_stop" => {
let i = v["index"].as_u64().unwrap_or(0) as usize;
if let Some(b) = blocks.get_mut(i) {
if b["type"] == "tool_use" {
let raw = partial_json.get(i).cloned().unwrap_or_default();
let input: Value = if raw.trim().is_empty() { json!({}) } else { serde_json::from_str(&raw).unwrap_or(json!({})) };
b["input"] = input.clone();
let call = ToolCall { id: b["id"].as_str().unwrap_or("").into(), name: b["name"].as_str().unwrap_or("").into(), args: input.to_string(), status: ToolStatus::Waiting };
send(StreamEvent::ToolCall(call.clone()));
out.tool_calls.push(call);
}
}
}
"message_delta" => {
if v["delta"]["stop_reason"] == "refusal" {
out.text.push_str("\n\n(模型拒絕回答這個要求。)");
send(StreamEvent::Text("\n\n(模型拒絕回答這個要求。)".into()));
} else if v["delta"]["stop_reason"] == "max_tokens" {
out.text.push_str("\n\n(回答太長,已達長度上限。)");
send(StreamEvent::Text("\n\n(回答太長,已達長度上限。)".into()));
}
}
"message_stop" => return false,
"error" => {
error = Some(v["error"]["message"].as_str().unwrap_or("未知錯誤").to_string());
return false;
}
_ => {}
}
true
})?;
if let Some(e) = error {
return Err(Failure::fatal(e));
}
out.blocks = blocks.into_iter().filter(|b| !b.is_null()).collect();
Ok(out)
}
fn connect_error(kind: ProviderKind, e: &str) -> String {
if kind == ProviderKind::Ollama {
format!("連不上 Ollama:{e}\n請先開啟 Ollama,並用 `ollama pull` 下載模型。")
} else {
format!("連不上 {}:{e}", kind.label())
}
}
/// OpenCode Go: which API style each model speaks, from the public models list (fetched once).
static OPENCODE_STYLES: std::sync::Mutex<Option<std::collections::HashMap<String, String>>> = std::sync::Mutex::new(None);
fn remember_opencode_styles(_base: &str, list: &Value) {
let map = opencode_styles_of(list);
if !map.is_empty() {
*OPENCODE_STYLES.lock().unwrap() = Some(map);
}
}
/// {"data":[{"id":"qwen3.8-max","endpoint":{"messages":{…}}}, …]} → id → "messages" | "chat-completions" | …
pub fn opencode_styles_of(list: &Value) -> std::collections::HashMap<String, String> {
list["data"]
.as_array()
.into_iter()
.flatten()
.filter_map(|m| {
let id = m["id"].as_str()?;
let ep = m["endpoint"].as_object()?;
// A model with a chat-completions door uses it (AnyBot's best-tested path); else the first.
let style = if ep.contains_key("chat-completions") { "chat-completions" } else if ep.contains_key("messages") { "messages" } else { ep.keys().next()? };
Some((id.to_string(), style.to_string()))
})
.collect()
}
/// The API style for a model: "messages" (Anthropic-style) or "chat-completions" (everything
/// else; OpenCode translates for models whose own style is Responses or Gemini).
pub fn opencode_go_style(base: &str, model: &str) -> String {
let known = OPENCODE_STYLES.lock().unwrap().as_ref().and_then(|m| m.get(model).cloned());
let style = known.or_else(|| {
let v: Value = agent().get(&format!("{base}/v1/models")).call().ok()?.into_body().read_json().ok()?;
remember_opencode_styles(base, &v);
OPENCODE_STYLES.lock().unwrap().as_ref().and_then(|m| m.get(model).cloned())
});
style.unwrap_or_else(|| {
// Offline guess from the published list: Claude, Qwen and MiniMax M3 are Anthropic-style.
let m = model.to_lowercase();
if m.starts_with("claude") || m.starts_with("qwen") || m == "minimax-m3" { "messages".into() } else { "chat-completions".into() }
})
}
fn opencode_session(req: &ChatRequest) -> String {
if req.session.is_empty() { "anybot".into() } else { req.session.clone() }
}
/// Ask the provider which models it offers.
pub fn list_models(kind: ProviderKind, base_url: &str, api_key: &str) -> Result<Vec<String>, String> {
if kind.needs_key() && api_key.is_empty() {
return Err("請先貼上 API 金鑰".into());
}
let url = if kind == ProviderKind::OpenCodeGo { format!("{base_url}/v1/models") } else { format!("{base_url}/models") };
let mut r = agent().get(&url);
if kind == ProviderKind::Anthropic {
r = r.header("x-api-key", api_key).header("anthropic-version", ANTHROPIC_VERSION);
} else if !api_key.is_empty() {
r = r.header("Authorization", &format!("Bearer {api_key}"));
}
let resp = r.call().map_err(|e| connect_error(kind, &e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.into_body().read_to_string().map_err(|e| e.to_string())?;
if status >= 400 {
return Err(http_error(status, &text, None).msg);
}
let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
if kind == ProviderKind::OpenCodeGo {
remember_opencode_styles(base_url, &v);
}
let list = v["data"].as_array().or_else(|| v["models"].as_array()).cloned().unwrap_or_default();
let mut ids: Vec<String> = list
.iter()
.filter_map(|m| m["id"].as_str().or_else(|| m["name"].as_str()))
.map(|s| s.trim_start_matches("models/").to_string())
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
return Err("供應商沒有回傳任何模型".into());
}
Ok(ids)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
/// Serve one canned SSE reply on a local port and return its base URL.
pub fn fake_server(body: &'static str) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
// Read the whole request (headers + body) before answering.
let mut req = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end]
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
.unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n";
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(body.as_bytes());
});
format!("http://127.0.0.1:{port}/v1")
}
fn run(kind: ProviderKind, body: &'static str) -> StepOutput {
let req = ChatRequest {
kind,
base_url: fake_server(body),
api_key: "test".into(),
model: "m".into(),
system: "s".into(),
history: vec![Message::user("hi")],
tools: crate::tools::specs(),
session: String::new(),
};
let cancel = AtomicBool::new(false);
match run_step(&req, &cancel, &|_| {}) {
Ok(o) => o,
Err(e) => panic!("{e}"),
}
}
#[test]
fn backoff_matches_original() {
assert_eq!([backoff_delay_ms(1), backoff_delay_ms(2), backoff_delay_ms(3)], [1000, 2000, 4000]);
assert!(http_error(429, "{}", None).retryable);
assert!(http_error(503, "{}", Some(2000)).retry_after_ms == Some(2000));
assert!(!http_error(401, "{}", None).retryable);
assert!(!http_error(400, "{\"error\":{\"message\":\"prompt is too long\"}}", None).retryable);
}
#[test]
fn openai_style_stream() {
let body = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"hm\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"!\"}}]}\n\ndata: [DONE]\n\n";
let o = run(ProviderKind::Xai, body);
assert_eq!(o.text, "你好!");
assert_eq!(o.thinking, "hm");
}
#[test]
fn openai_tool_calls_in_pieces() {
let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n";
let o = run(ProviderKind::OpenAi, body);
assert_eq!(o.tool_calls.len(), 1);
assert_eq!(o.tool_calls[0].name, "read_file");
assert_eq!(o.tool_calls[0].args, "{\"path\":\"a.txt\"}");
}
#[test]
fn claude_style_stream() {
let body = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ok\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig\"}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"list_dir\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\": \\\"x\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
let o = run(ProviderKind::Anthropic, body);
assert_eq!(o.text, "Hello");
assert_eq!(o.thinking, "ok");
assert_eq!(o.tool_calls[0].name, "list_dir");
assert_eq!(o.blocks.len(), 3);
assert_eq!(o.blocks[0]["signature"], "sig");
assert_eq!(o.blocks[2]["input"]["path"], "x");
}
#[test]
fn history_keeps_tool_turns() {
let mut a = Message::assistant("", "x");
a.tool_calls = vec![ToolCall { id: "c1".into(), name: "list_dir".into(), args: "{}".into(), status: ToolStatus::Done }];
let mut s = Message::assistant("old stuff", "");
s.summary = true;
let req = ChatRequest {
kind: ProviderKind::Anthropic,
base_url: String::new(),
api_key: String::new(),
model: "m".into(),
system: "sys".into(),
history: vec![s, Message::user("hi"), a, Message::tool_result("c1", "list_dir", "a.txt"), Message::user("next")],
tools: vec![],
session: String::new(),
};
let m = anthropic_messages(&req);
assert_eq!(m.len(), 3);
assert_eq!(m[1]["content"][0]["type"], "tool_use");
assert_eq!(m[2]["content"][0]["type"], "tool_result");
assert_eq!(m[2]["content"][1]["text"], "next");
let o = openai_messages(&ChatRequest { kind: ProviderKind::OpenAi, ..req });
assert!(o[0]["content"].as_str().unwrap().contains("old stuff"));
assert_eq!(o[3]["role"], "tool");
}
#[test]
fn screenshots_reach_the_model_and_old_ones_drop() {
let n = crate::computer::KEEP_SCREENSHOTS + 1;
let mut history = vec![Message::user("look")];
for i in 0..n {
let mut a = Message::assistant("", "x");
a.tool_calls = vec![ToolCall { id: format!("s{i}"), name: "computer_use_screenshot".into(), args: "{}".into(), status: ToolStatus::Done }];
history.push(a);
let mut r = Message::tool_result(&format!("s{i}"), "computer_use_screenshot", "shot");
r.images = vec![format!("IMG{i}")];
history.push(r);
}
let req = ChatRequest { kind: ProviderKind::Anthropic, base_url: String::new(), api_key: String::new(), model: "m".into(), system: String::new(), history, tools: vec![], session: String::new() };
let m = anthropic_messages(&req);
let all = serde_json::to_string(&m).unwrap();
assert!(!all.contains("IMG0") && all.contains(OLD_SCREENSHOT_NOTE));
assert!(all.contains(&format!("IMG{}", n - 1)) && all.contains("\"media_type\":\"image/jpeg\""));
let o = openai_messages(&ChatRequest { kind: ProviderKind::OpenAi, ..req });
// Each tool answer is followed by its picture as a user message (never between tool results).
for w in o.windows(2) {
if w[0]["role"] == "user" && w[0]["content"].is_array() && w[1]["role"] == "tool" {
panic!("picture between tool results");
}
}
let pics: Vec<&Value> = o.iter().filter(|x| x["role"] == "user" && x["content"].is_array()).collect();
assert_eq!(pics.len(), crate::computer::KEEP_SCREENSHOTS);
assert!(pics.last().unwrap()["content"][1]["image_url"]["url"].as_str().unwrap().starts_with("data:image/jpeg;base64,IMG"));
}
/// A server that records the request it gets, then answers with `body`.
fn recording_server(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = tx.send(String::from_utf8_lossy(&req).to_string());
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
});
(format!("http://127.0.0.1:{port}"), rx)
}
#[test]
fn opencode_go_routes_each_model_its_way() {
// The public list's shape (trimmed).
let list: Value = serde_json::from_str(r#"{"data":[
{"id":"kimi-k3","endpoint":{"chat-completions":{"base_url":"https://opencode.ai/inference/go/openai/v1"}}},
{"id":"qwen3.8-max","endpoint":{"messages":{"base_url":"https://opencode.ai/inference/go/anthropic/v1"}}},
{"id":"grok-4.7","endpoint":{"responses":{"base_url":"https://opencode.ai/inference/go/openai/v1"}}}]}"#).unwrap();
let styles = opencode_styles_of(&list);
assert_eq!(styles["kimi-k3"], "chat-completions");
assert_eq!(styles["qwen3.8-max"], "messages");
assert_eq!(styles["grok-4.7"], "responses");
remember_opencode_styles("", &list);
let cancel = AtomicBool::new(false);
let ask = |model: &str, body: &'static str| {
let (base, rx) = recording_server(body);
let req = ChatRequest { kind: ProviderKind::OpenCodeGo, base_url: base, api_key: "sk-go".into(), model: model.into(), system: "s".into(), history: vec![Message::user("hi")], tools: vec![], session: "anybot-bot-7".into() };
let out = run_step(&req, &cancel, &|_| {}).unwrap();
(out, rx.recv().unwrap())
};
let (out, seen) = ask("kimi-k3", "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n\n");
assert_eq!(out.text, "你好");
assert!(seen.starts_with("POST /openai/v1/chat/completions "), "{seen}");
let low = seen.to_lowercase();
assert!(low.contains("authorization: bearer sk-go") && low.contains("x-opencode-session: anybot-bot-7"), "{seen}");
let (out, seen) = ask("qwen3.8-max", "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"嗨\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n");
assert_eq!(out.text, "嗨");
assert!(seen.starts_with("POST /anthropic/v1/messages "), "{seen}");
let low = seen.to_lowercase();
assert!(low.contains("x-api-key: sk-go") && low.contains("x-opencode-session: anybot-bot-7"), "{seen}");
// Responses-style models go through OpenCode's chat-completions translation.
let (_, seen) = ask("grok-4.7", "data: [DONE]\n\n");
assert!(seen.starts_with("POST /openai/v1/chat/completions "), "{seen}");
}
/// Network: OpenCode Go's real model list (public) and the style of a few models.
#[test]
#[ignore]
fn opencode_go_live_list() {
// A wrong key is refused (so pasting a bad key shows 金鑰無效 right away).
assert!(list_models(ProviderKind::OpenCodeGo, OPENCODE_GO_BASE, "sk-wrong").unwrap_err().contains("401"));
// The styles come from the public list (no key).
assert_eq!(opencode_go_style(OPENCODE_GO_BASE, "qwen3.8-max"), "messages");
assert_eq!(opencode_go_style(OPENCODE_GO_BASE, "glm-5.3"), "chat-completions");
}
#[test]
fn a_model_that_cannot_see_gets_no_pictures() {
let _g = vision_test_guard();
assert!(image_refused("錯誤 400:At most 0 image(s) may be provided in one prompt. (parameter=image)"));
assert!(image_refused("This model does not support image input"));
assert!(image_refused("unknown variant `image_url`, expected `text`"));
assert!(!image_refused("錯誤 400:Invalid credential"));
// The first request fails like the user's model did; the retry has no picture.
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
for (n, s) in l.incoming().flatten().enumerate() {
let mut s = s;
let mut req = Vec::new();
let mut buf = [0u8; 65536];
loop {
let k = s.read(&mut buf).unwrap_or(0);
if k == 0 {
break;
}
req.extend_from_slice(&buf[..k]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = tx.send(String::from_utf8_lossy(&req).to_string());
if n == 0 {
let body = r#"{"error":{"message":"At most 0 image(s) may be provided in one prompt. (parameter=image)"}}"#;
let _ = write!(s, "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
} else {
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n");
}
}
});
let mut shot = Message::tool_result("c1", "computer_use_screenshot", "你的螢幕截圖");
shot.images = vec!["SHOTDATA".into()];
let mut asked = Message::assistant("", "");
asked.tool_calls = vec![ToolCall { id: "c1".into(), name: "computer_use_screenshot".into(), args: "{}".into(), status: ToolStatus::Done }];
let req = ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "blind-model".into(), system: String::new(), history: vec![Message::user("看一下畫面"), asked, shot], tools: vec![], session: String::new() };
let cancel = AtomicBool::new(false);
let out = run_step(&req, &cancel, &|_| {}).unwrap();
assert_eq!(out.text, "ok");
let first = rx.recv().unwrap();
let second = rx.recv().unwrap();
assert!(first.contains("SHOTDATA") && !second.contains("SHOTDATA") && second.contains("看不到圖片"), "{second}");
assert!(!sees_images(&req));
}
/// A fake model: refuses pictures when `blind`, else answers `reply`; hands over each request.
fn fake_model(blind: bool, reply: &'static str) -> (String, std::sync::mpsc::Receiver<String>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
for s in l.incoming().flatten() {
let mut s = s;
let mut req = Vec::new();
let mut buf = [0u8; 65536];
loop {
let k = s.read(&mut buf).unwrap_or(0);
if k == 0 {
break;
}
req.extend_from_slice(&buf[..k]);
let text = String::from_utf8_lossy(&req).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let len = text[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let text = String::from_utf8_lossy(&req).to_string();
let _ = tx.send(text.clone());
if blind && text.contains("image_url") {
let body = r#"{"error":{"message":"This model does not support image input"}}"#;
let _ = write!(s, "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
} else {
let chunk = serde_json::json!({"choices": [{"delta": {"content": reply}}]});
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {chunk}\n\ndata: [DONE]\n\n");
}
}
});
(format!("http://127.0.0.1:{port}/v1"), rx)
}
#[test]
fn a_model_that_sees_describes_pictures_for_one_that_cannot() {
let _g = vision_test_guard();
let (blind_url, blind_rx) = fake_model(true, "好的,我看到描述了");
let (eyes_url, eyes_rx) = fake_model(false, "一張報表截圖,上面寫著「十月營收 42 萬」。");
set_eyes(vec![
// Not there (a typo in a model name, a dead server…): the next one is asked.
Eyes { kind: ProviderKind::Custom, base_url: "http://127.0.0.1:9/v1".into(), api_key: String::new(), model: "gone".into() },
Eyes { kind: ProviderKind::Custom, base_url: eyes_url, api_key: String::new(), model: "eyes-model".into() },
]);
let mut photo = Message::user("幫我看這張圖");
photo.images = vec!["UElDVFVSRS1PTkU=".into()];
let req = ChatRequest { kind: ProviderKind::Custom, base_url: blind_url, api_key: String::new(), model: "text-only".into(), system: String::new(), history: vec![photo], tools: vec![], session: String::new() };
let said = std::sync::Mutex::new(vec![]);
let out = run_step(&req, &AtomicBool::new(false), &|e| {
if let StreamEvent::Retrying(m) = e {
said.lock().unwrap().push(m);
}
})
.unwrap();
assert_eq!(out.text, "好的,我看到描述了");
// The helper got the picture and what the person said with it.
let helper = eyes_rx.recv().unwrap();
assert!(helper.contains("UElDVFVSRS1PTkU=") && helper.contains("幫我看這張圖"));
// The blind model got the description instead of the picture.
let _refused = blind_rx.recv().unwrap();
let again = blind_rx.recv().unwrap();
assert!(!again.contains("UElDVFVSRS1PTkU=") && again.contains("十月營收 42 萬") && again.contains("eyes-model"), "{again}");
assert!(said.lock().unwrap().iter().any(|m| m.contains("看得懂圖")));
// The same picture later: described from memory, no second call to the helper.
let mut photo2 = Message::user("再看一次");
photo2.images = vec!["UElDVFVSRS1PTkU=".into()];
let req2 = ChatRequest { history: vec![photo2], ..req.clone() };
run_step(&req2, &AtomicBool::new(false), &|_| {}).unwrap();
assert!(blind_rx.recv().unwrap().contains("十月營收 42 萬"));
assert!(eyes_rx.recv_timeout(std::time::Duration::from_millis(300)).is_err());
set_eyes(vec![]);
}
#[test]
fn steps_without_results_are_closed() {
let mut a = Message::assistant("", "");
a.tool_calls = vec![
ToolCall { id: "t1".into(), name: "shell".into(), args: "{}".into(), status: ToolStatus::Denied },
ToolCall { id: "t2".into(), name: "browser_fill_login".into(), args: "{}".into(), status: ToolStatus::Waiting },
];
let h = vec![Message::user("a"), a, Message::tool_result("t2", "browser_fill_login", "ok"), Message::tool_result("zz", "x", "stray"), Message::user("再一次")];
let r = repair_history(&h);
let roles: Vec<String> = r.iter().map(|m| format!("{:?}:{}", m.role, m.tool_call_id)).collect();
assert_eq!(roles, vec!["User:", "Assistant:", "Tool:t2", "Tool:t1", "User:"]);
assert!(r[3].text.contains("沒有允許"));
// The OpenAI-style body pairs every call with a result.
let req = ChatRequest { kind: ProviderKind::Custom, base_url: String::new(), api_key: String::new(), model: "m".into(), system: String::new(), history: r, tools: vec![], session: String::new() };
let msgs = openai_messages(&req);
let i = msgs.iter().position(|m| m["tool_calls"].is_array()).unwrap();
assert_eq!(msgs[i + 1]["role"], "tool");
assert_eq!(msgs[i + 2]["role"], "tool");
}
}