Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ src/paraglide/
*.sw?

target/

.reference
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
- 前端: React 19 + TypeScript + Vite + Tailwind CSS v4 + shadcn/ui(pnpm dlx shadcn@latest add xxx)
- 后端: Rust (Edition 2021) + Tokio + Axum
- 桌面框架: Tauri 2
- 代理转发/转换参考: [QuantumNous/new-api](https://github.com/QuantumNous/new-api)

## 参考项目

- 代理转发/转换参考[new-api](.reference/new-api)
- kiro、codex、antigravity等2api参考[CLIProxyAPIPlus](.reference/CLIProxyAPIPlus)
- CLIProxyAPIPlus的可视化app参考[quotio](.reference/quotio)

---

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ Notes:
- Cross-format fallback/conversion is controlled by `upstreams[].convert_from_map` (no global switch). If a provider has no eligible upstream for the inbound format, it won't be selected.
- If `openai` is missing for `/v1/chat/completions`: fallback can be `openai-response`, `anthropic`, or `gemini` (priority-based; tie-break prefers `openai-response`).
- For `/v1/messages`: choose between `anthropic` and `kiro` by priority; tie-break uses upstream id. If the chosen provider returns a retryable error, the proxy will fall back to the other native provider (Anthropic ↔ Kiro) when configured.
- If neither `anthropic` nor `kiro` exists for `/v1/messages`: fallback can be `openai-response`, `openai`, or `gemini` when the target provider is allowed for `anthropic_messages` via `convert_from_map`.
- If neither `anthropic` nor `kiro` exists for `/v1/messages`:
- `antigravity` is supported by default (no `convert_from_map` needed; aligned with CLIProxyAPIPlus Antigravity/Claude Code behavior).
- Other providers can be selected only when allowed for `anthropic_messages` via `convert_from_map` (e.g. `openai-response`, `openai`, `gemini`).
- If `openai-response` is missing for `/v1/responses`: fallback can be `openai`, `anthropic`, or `gemini` (priority-based; tie-break prefers `openai`).
- If `gemini` is missing for `/v1beta/models/*:generateContent`: fallback can be `openai-response`, `openai`, or `anthropic` (priority-based; tie-break prefers `openai-response`).

Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ pnpm exec tsc --noEmit
- 跨格式 fallback/转换由 `upstreams[].convert_from_map` 控制(不再有全局开关);若某个 provider 在该入站格式下没有任何可用 upstream,则不会被选中。
- `/v1/chat/completions` 缺少 `openai`:可 fallback 到 `openai-response` / `anthropic` / `gemini`(按优先级选择,平级优先 `openai-response`)
- `/v1/messages`:在 `anthropic` 与 `kiro` 间按优先级选择;平级按 upstream id 排序。若命中 provider 返回“可重试错误”,且另一个 native provider 已配置,则会自动 fallback(Anthropic ↔ Kiro)
- 当 `/v1/messages` 缺少 `anthropic` 且 `kiro` 也不存在时:若目标 provider 在 `convert_from_map` 中允许 `anthropic_messages`,则可 fallback 到 `openai-response` / `openai` / `gemini`(按优先级选择,平级优先 `openai-response`)
- 当 `/v1/messages` 缺少 `anthropic` 且 `kiro` 也不存在时:
- `antigravity`:默认支持(无需 `convert_from_map`,对齐 CLIProxyAPIPlus 的 Antigravity/Claude Code 体验)
- 其它 provider:若在 `convert_from_map` 中允许 `anthropic_messages`,则可 fallback 到 `openai-response` / `openai` / `gemini`(按优先级选择,平级优先 `openai-response`)
- `/v1/responses` 缺少 `openai-response`:可 fallback 到 `openai` / `anthropic` / `gemini`(按优先级选择,平级优先 `openai`)
- `/v1beta/models/*:generateContent` 缺少 `gemini`:可 fallback 到 `openai-response` / `openai` / `anthropic`(按优先级选择,平级优先 `openai-response`)

Expand Down
3 changes: 2 additions & 1 deletion crates/token_proxy_core/src/antigravity/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ pub(crate) const BASE_URL_DAILY: &str = "https://daily-cloudcode-pa.googleapis.c
pub(crate) const BASE_URL_SANDBOX: &str = "https://daily-cloudcode-pa.sandbox.googleapis.com";
pub(crate) const BASE_URL_PROD: &str = "https://cloudcode-pa.googleapis.com";

pub(crate) const BASE_URLS: [&str; 3] = [BASE_URL_SANDBOX, BASE_URL_DAILY, BASE_URL_PROD];
// Align with CLIProxyAPIPlus: prefer daily, then sandbox. Prod is intentionally excluded.
pub(crate) const BASE_URLS: [&str; 2] = [BASE_URL_DAILY, BASE_URL_SANDBOX];

const ANTIGRAVITY_VERSION: &str = "1.104.0";

Expand Down
45 changes: 44 additions & 1 deletion crates/token_proxy_core/src/proxy/anthropic_compat/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,54 @@ fn claude_content_to_blocks(content: Option<&Value>) -> Vec<Value> {
};
match content {
Value::String(text) => vec![json!({ "type": "text", "text": text })],
Value::Array(items) => items.clone(),
Value::Array(items) => items
.iter()
.cloned()
.map(|mut item| {
normalize_text_block_in_place(&mut item);
item
})
.collect(),
_ => Vec::new(),
}
}

fn normalize_text_block_in_place(block: &mut Value) {
let Some(object) = block.as_object_mut() else {
return;
};
let block_type = object.get("type").and_then(Value::as_str).unwrap_or("");
if block_type != "text" {
return;
}
let text_value = object.get("text");
let new_text = text_value.and_then(extract_text_value);
if let Some(new_text) = new_text {
object.insert("text".to_string(), Value::String(new_text));
return;
}
// If text exists but is not convertible, coerce to empty string to satisfy schema.
if text_value.is_some() {
object.insert("text".to_string(), Value::String(String::new()));
}
}

fn extract_text_value(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.to_string()),
Value::Object(object) => {
if let Some(text) = object.get("text") {
return extract_text_value(text);
}
if let Some(text) = object.get("value") {
return extract_text_value(text);
}
None
}
_ => None,
}
}

fn push_claude_message(messages: &mut Vec<Value>, role: &str, blocks: Vec<Value>) {
let content = blocks;
if content.is_empty() {
Expand Down
Loading