AnyBot/src/providers.rs

1552 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
/// 需要處理: a step only the person can do on a computer (signing in, a captcha…). `reason`
/// is what to do, `grant` the computer's name; Once = 「我完成了」, Reject = 「跳過」 (the
/// original's box hand-off card).
Handoff,
}
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");
}
}