mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2f6485d8 | |||
| dc4524e960 | |||
| 34f16886a2 |
@@ -0,0 +1,364 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding assistants (Claude Code, Codex, Gemini CLI, …) working in this
|
||||
repository. Claude Code reads this file automatically; a local `CLAUDE.md` (gitignored
|
||||
per `.gitignore`) may override or extend it per-developer.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**CC Switch** is a cross-platform Tauri 2 desktop application that manages configurations
|
||||
for multiple AI coding CLIs: **Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw**.
|
||||
It provides provider switching, unified MCP/Prompts/Skills management, a local proxy with
|
||||
failover, usage tracking, session browsing, and cloud sync — all backed by a SQLite SSOT.
|
||||
|
||||
- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS 3.4 + shadcn/ui
|
||||
- **Backend**: Rust (Tauri 2.8) with SQLite (`rusqlite`) persistence
|
||||
- **State/cache**: TanStack Query v5 on the frontend; `Mutex<Connection>` on the backend
|
||||
- **IPC**: Tauri commands (camelCase names) wrapped by a typed frontend API layer
|
||||
- **i18n**: `react-i18next` with `zh` / `en` / `ja` locales (Chinese is the primary UI language)
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
├── src/ # Frontend (React + TypeScript)
|
||||
│ ├── App.tsx # Root shell — view routing, headers, dialogs
|
||||
│ ├── main.tsx # Bootstrap, providers, config-error handling
|
||||
│ ├── components/
|
||||
│ │ ├── providers/ # Provider CRUD (cards, forms, dialogs)
|
||||
│ │ ├── mcp/ # Unified MCP panel + wizard
|
||||
│ │ ├── prompts/ # Prompts panel (Markdown editor)
|
||||
│ │ ├── skills/ # Skills install/management + repo manager
|
||||
│ │ ├── sessions/ # Session manager (history browser)
|
||||
│ │ ├── proxy/ # Proxy + failover panels
|
||||
│ │ ├── openclaw/ # OpenClaw-specific config panels
|
||||
│ │ ├── settings/ # Settings pages (theme, dir, webdav, proxy, about…)
|
||||
│ │ ├── deeplink/ # ccswitch:// import confirmation dialogs
|
||||
│ │ ├── env/ # Env conflict warning banner
|
||||
│ │ ├── universal/ # Cross-app (universal) provider UI
|
||||
│ │ ├── usage/ # Usage dashboard, charts, pricing
|
||||
│ │ ├── workspace/ # OpenClaw workspace/agent file editor
|
||||
│ │ └── ui/ # shadcn/ui primitives (button, dialog, ...)
|
||||
│ ├── hooks/ # Custom React hooks (business logic glue)
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Typed Tauri IPC wrappers (one module per domain)
|
||||
│ │ ├── query/ # TanStack Query config + query keys
|
||||
│ │ ├── schemas/ # Zod schemas (provider/mcp/settings/common)
|
||||
│ │ ├── errors/ # Error parsing helpers
|
||||
│ │ ├── utils/ # Small helpers (base64, ...)
|
||||
│ │ ├── authBinding.ts # Auth binding helpers
|
||||
│ │ ├── clipboard.ts # Clipboard utils
|
||||
│ │ ├── platform.ts # OS detection (isMac/isWin/isLinux)
|
||||
│ │ └── updater.ts # Updater helpers
|
||||
│ ├── contexts/UpdateContext.tsx
|
||||
│ ├── i18n/ # i18next init + locales (en/zh/ja)
|
||||
│ ├── config/ # Static presets (providers, mcp)
|
||||
│ ├── icons/ # Provider icon index
|
||||
│ ├── types.ts, types/ # Shared TypeScript types
|
||||
│ └── utils/ # DOM/error helpers
|
||||
│
|
||||
├── src-tauri/ # Backend (Rust + Tauri 2)
|
||||
│ ├── Cargo.toml # rust-version = 1.85
|
||||
│ ├── tauri.conf.json # Deep link, updater, bundling config
|
||||
│ ├── capabilities/ # Tauri permission manifests
|
||||
│ └── src/
|
||||
│ ├── lib.rs # App entry, tray, deep-link, setup
|
||||
│ ├── main.rs # Binary entry delegating to lib
|
||||
│ ├── commands/ # Tauri #[command] layer (by domain, mod.rs re-exports *)
|
||||
│ │ # auth, provider, mcp, prompt, skill, proxy,
|
||||
│ │ # session_manager, settings, usage, webdav_sync, …
|
||||
│ ├── services/ # Business-logic layer
|
||||
│ │ ├── provider/ # ProviderService (CRUD, switch, live sync, auth, usage)
|
||||
│ │ ├── mcp.rs # McpService
|
||||
│ │ ├── prompt.rs # PromptService
|
||||
│ │ ├── skill.rs # SkillService
|
||||
│ │ ├── proxy.rs # ProxyService (hot-switching local proxy)
|
||||
│ │ ├── config.rs # ConfigService (import/export, backups)
|
||||
│ │ ├── speedtest.rs # Endpoint latency
|
||||
│ │ ├── webdav*.rs # WebDAV sync engine + auto-sync
|
||||
│ │ └── usage_stats.rs # Usage aggregation
|
||||
│ ├── database/
|
||||
│ │ ├── mod.rs # Database struct, Mutex<Connection>, hooks
|
||||
│ │ ├── schema.rs # Schema + migration (SCHEMA_VERSION = 6)
|
||||
│ │ ├── migration.rs # JSON → SQLite migration
|
||||
│ │ ├── backup.rs # Snapshot + SQL export
|
||||
│ │ └── dao/ # providers, mcp, prompts, skills, settings, proxy,
|
||||
│ │ # failover, stream_check, usage_rollup, universal_providers
|
||||
│ ├── proxy/ # Local HTTP proxy (forwarder, circuit breaker, SSE,
|
||||
│ │ # failover, model mapping, thinking rectifier, …)
|
||||
│ ├── mcp/ # MCP live-file sync per app
|
||||
│ ├── session_manager/ # Conversation history browser
|
||||
│ ├── deeplink/ # ccswitch:// URL parser + importer
|
||||
│ ├── store.rs # AppState (Arc<Database>, caches)
|
||||
│ ├── config.rs # Paths helper (get_app_config_dir, …)
|
||||
│ ├── app_config.rs # AppType, MultiAppConfig, domain models
|
||||
│ ├── provider.rs # Provider model
|
||||
│ ├── {claude,codex,gemini,opencode,openclaw}_config.rs # Per-app live-file IO
|
||||
│ ├── {claude_mcp,claude_plugin,gemini_mcp}.rs # App-specific helpers
|
||||
│ ├── settings.rs # AppSettings
|
||||
│ ├── tray.rs # System tray + quick switch
|
||||
│ ├── error.rs # AppError (thiserror)
|
||||
│ ├── panic_hook.rs
|
||||
│ └── ...
|
||||
│ └── tests/ # Rust integration tests (provider, mcp, deeplink, skill, …)
|
||||
│
|
||||
├── tests/ # Frontend test suite (vitest)
|
||||
│ ├── setupGlobals.ts, setupTests.ts
|
||||
│ ├── msw/ # MSW handlers + tauri IPC mocks + state
|
||||
│ ├── components/ # Component tests
|
||||
│ ├── hooks/ # Hook tests
|
||||
│ ├── integration/ # App-level flows
|
||||
│ ├── config/ # Preset sanity tests
|
||||
│ └── utils/ # testQueryClient + helpers
|
||||
│
|
||||
├── docs/ # User manual, release notes, proxy guide
|
||||
├── scripts/ # Icon extraction & index generation
|
||||
├── assets/ # Screenshots, partner logos
|
||||
├── flatpak/ # Flatpak build instructions
|
||||
├── package.json # pnpm scripts (dev/build/typecheck/test/format)
|
||||
├── vite.config.ts # root = src, alias @ → src
|
||||
├── vitest.config.ts # jsdom + setup files
|
||||
├── tsconfig.json # strict; noUnusedLocals/Parameters
|
||||
├── tailwind.config.cjs, postcss.config.cjs, components.json (shadcn)
|
||||
└── README.md / README_ZH.md / README_JA.md / CHANGELOG.md / CONTRIBUTING.md
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React + TS) │
|
||||
│ Components → Hooks (business logic) → TanStack Query │
|
||||
│ │ │
|
||||
│ src/lib/api/* (typed invoke wrappers) │
|
||||
└────────────────────────────┬────────────────────────────────┘
|
||||
│ Tauri IPC (camelCase commands)
|
||||
┌────────────────────────────▼────────────────────────────────┐
|
||||
│ Backend (Rust + Tauri 2.8) │
|
||||
│ commands/* (#[tauri::command]) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ services/* (ProviderService, McpService, PromptService, │
|
||||
│ SkillService, ProxyService, ConfigService…) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ database/dao/* → Mutex<rusqlite::Connection> │
|
||||
│ │
|
||||
│ + per-app live-file writers (claude/codex/gemini/…) │
|
||||
│ + proxy/ (hyper + rustls local HTTP proxy) │
|
||||
│ + session_manager/, deeplink/, mcp/, tray, updater │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Design Principles
|
||||
|
||||
- **Single Source of Truth (SSOT)** — SQLite at `~/.cc-switch/cc-switch.db` holds providers,
|
||||
MCP, prompts, skills, settings. Syncable state lives in the DB; device-level UI
|
||||
preferences live in `~/.cc-switch/settings.json`.
|
||||
- **Dual-way live sync** — On switch, services write the active provider into the CLI's
|
||||
real config files (e.g. `~/.claude/settings.json`, `~/.codex/config.toml`). When editing
|
||||
the currently active provider, changes are backfilled from the live file first to avoid
|
||||
losing edits the user made outside the app.
|
||||
- **Atomic writes** — Write to a temp file and rename. Never overwrite a live config
|
||||
in-place.
|
||||
- **Concurrency safety** — `Database` wraps `rusqlite::Connection` in a `Mutex`, exposed
|
||||
through `AppState` as `Arc<Database>`. Use the `lock_conn!` macro (see
|
||||
`src-tauri/src/database/mod.rs`) instead of raw `.lock().unwrap()`.
|
||||
- **Layered backend** — `commands → services → dao → database`. Commands must stay thin;
|
||||
put business logic in services. DAOs are the only layer that touches SQL.
|
||||
- **Auto backups** — `~/.cc-switch/backups/` keeps the 10 most recent snapshots;
|
||||
`~/.cc-switch/skill-backups/` keeps up to 20 before skill uninstall.
|
||||
|
||||
### Key Services
|
||||
|
||||
| Service | Responsibility |
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| `ProviderService` | Provider CRUD, switching, live-file sync, backfill, sort, auth, usage |
|
||||
| `McpService` | MCP server CRUD + bidirectional sync across Claude/Codex/Gemini/OpenCode|
|
||||
| `PromptService` | Prompt presets, active sync to `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
|
||||
| `SkillService` | Skill install from GitHub/ZIP, symlink or copy mode, repo management |
|
||||
| `ProxyService` | Local HTTP proxy (hyper+rustls) with hot-switch, failover, rectifiers |
|
||||
| `ConfigService` | Import/export, backup rotation |
|
||||
| `SpeedtestService` | API endpoint latency probing |
|
||||
|
||||
### Data Locations
|
||||
|
||||
- `~/.cc-switch/cc-switch.db` — SQLite SSOT (schema version 6)
|
||||
- `~/.cc-switch/settings.json` — device-level UI preferences
|
||||
- `~/.cc-switch/backups/` — auto-rotated DB snapshots (keeps 10)
|
||||
- `~/.cc-switch/skills/` — skills (symlinked into each app by default)
|
||||
- `~/.cc-switch/skill-backups/` — pre-uninstall skill backups (keeps 20)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 22.12** (see `.node-version`) — 18+ works but CI pins 20
|
||||
- **pnpm 10.12.3** (pinned in CI; pnpm-workspace)
|
||||
- **Rust 1.85+** (pinned in `Cargo.toml`)
|
||||
- **Tauri 2.0 system deps** — see https://v2.tauri.app/start/prerequisites/
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install frontend deps
|
||||
pnpm dev # Run full app (tauri dev with hot reload)
|
||||
pnpm dev:renderer # Vite-only (no Tauri shell) — useful for UI-only work
|
||||
pnpm build # Production build (tauri build)
|
||||
pnpm typecheck # tsc --noEmit (strict)
|
||||
pnpm format # Prettier write on src/**
|
||||
pnpm format:check # Prettier check (CI)
|
||||
pnpm test:unit # vitest run
|
||||
pnpm test:unit:watch # vitest in watch mode
|
||||
```
|
||||
|
||||
Rust backend (from `src-tauri/`):
|
||||
|
||||
```bash
|
||||
cargo fmt # Format
|
||||
cargo fmt --check # CI format check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test # Backend + integration tests
|
||||
cargo test --features test-hooks
|
||||
```
|
||||
|
||||
### Pre-submission Checklist
|
||||
|
||||
CI will run these; run locally before opening a PR:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm format:check && pnpm test:unit
|
||||
cd src-tauri && cargo fmt --check && cargo clippy -- -D warnings && cargo test
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- **Frontend**: `vitest` + `jsdom` + `@testing-library/react`. Tauri `invoke` is mocked via
|
||||
`tests/msw/tauriMocks.ts`; network requests are mocked with MSW. Shared state
|
||||
(providers etc.) is reset between tests in `tests/setupTests.ts`.
|
||||
- **Test query client**: use `tests/utils/testQueryClient.ts` instead of the app client —
|
||||
it disables retries/cache for deterministic tests.
|
||||
- **Backend**: integration tests live in `src-tauri/tests/`; unit tests are co-located in
|
||||
modules. Many tests use `serial_test::serial` because they mutate `HOME`/env — do not
|
||||
run them with parallelism hacks, and don't remove the `#[serial]` attribute.
|
||||
- **Rust test-only hooks**: the `test-hooks` cargo feature gates extra test instrumentation.
|
||||
|
||||
### CI (`.github/workflows/ci.yml`)
|
||||
|
||||
Two jobs on PRs and pushes to `main`:
|
||||
|
||||
1. **Frontend Checks** (ubuntu-latest): `pnpm typecheck`, `pnpm format:check`, `pnpm test:unit`
|
||||
2. **Backend Checks** (ubuntu-22.04): installs GTK/WebKit deps, then
|
||||
`cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`
|
||||
|
||||
## Conventions
|
||||
|
||||
### Tauri 2.0 IPC
|
||||
|
||||
- **Command names are camelCase** on the JS side (e.g. `getProviders`, `switchProvider`).
|
||||
On the Rust side, the `#[tauri::command]` functions use snake_case with the
|
||||
`#![allow(non_snake_case)]` at the crate boundary in `commands/mod.rs`.
|
||||
- **Never call `invoke` directly in components** — add the call to `src/lib/api/*.ts`
|
||||
with a typed signature, then import from `@/lib/api`. See `src/lib/api/providers.ts`
|
||||
for the pattern.
|
||||
- **Payloads use camelCase**: Rust types carry `#[serde(rename_all = "camelCase")]` where
|
||||
they cross the IPC boundary.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Import alias**: `@/` resolves to `src/` (configured in `vite.config.ts`, `tsconfig.json`,
|
||||
`vitest.config.ts`). Use `@/components/...`, `@/lib/...`, `@/hooks/...`.
|
||||
- **Data access**: Prefer TanStack Query hooks from `src/lib/query/` (e.g.
|
||||
`useProvidersQuery`, `useSettingsQuery`) rather than calling the API layer ad-hoc —
|
||||
they own cache keys and invalidation.
|
||||
- **Forms**: `react-hook-form` + `zod` resolvers; schemas live in `src/lib/schemas/`.
|
||||
- **UI kit**: shadcn/ui primitives under `src/components/ui/`. Configure new primitives
|
||||
via `components.json` (`npx shadcn add ...`). Icons: `lucide-react`.
|
||||
- **Styling**: Tailwind utility classes; use the `cn()` helper from `@/lib/utils`.
|
||||
Dark/light/system theme is controlled by `ThemeProvider`.
|
||||
- **State strictness**: `noUnusedLocals` and `noUnusedParameters` are on — prefix
|
||||
intentionally-unused args with `_`.
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
- **Errors**: return `Result<T, AppError>` (from `src-tauri/src/error.rs`, built on
|
||||
`thiserror`). Do not `unwrap()` outside tests; use `?` and map into `AppError`.
|
||||
- **Concurrency**: never hold a DB lock across an `.await`. Use the `lock_conn!` macro
|
||||
from `database/mod.rs` for short critical sections.
|
||||
- **JSON serialization**: use `database::to_json_string` for DB payloads to avoid panics.
|
||||
- **Live-file IO**: always go through the per-app writer modules
|
||||
(`claude_config.rs`, `codex_config.rs`, etc.) — they implement atomic temp+rename.
|
||||
- **Adding a new Tauri command**:
|
||||
1. Implement logic in the appropriate `services/*` module.
|
||||
2. Add a thin `#[tauri::command]` wrapper in `src-tauri/src/commands/<domain>.rs`.
|
||||
3. Register it in the `tauri::generate_handler!` list in `src-tauri/src/lib.rs`.
|
||||
4. Add the typed wrapper to `src/lib/api/<domain>.ts` and re-export from
|
||||
`src/lib/api/index.ts`.
|
||||
5. If it touches DB schema, bump `SCHEMA_VERSION` in `database/mod.rs` and add a
|
||||
migration step in `database/schema.rs` or `database/migration.rs`.
|
||||
|
||||
### Internationalization
|
||||
|
||||
CC Switch ships **three locales** and requires all of them to stay in sync:
|
||||
|
||||
- `src/i18n/locales/en.json`
|
||||
- `src/i18n/locales/zh.json` (primary)
|
||||
- `src/i18n/locales/ja.json`
|
||||
|
||||
Rules:
|
||||
|
||||
1. Never hardcode user-visible strings. Always use `t('namespace.key')` from
|
||||
`react-i18next`.
|
||||
2. When adding/renaming a key, update **all three** files.
|
||||
3. When removing a key, delete it from all three files.
|
||||
4. Chinese is the authoritative source for meaning — follow the tone of existing zh
|
||||
strings when writing new ones.
|
||||
|
||||
### Commit Style
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(provider): add AWS Bedrock preset
|
||||
fix(tray): resolve menu not refreshing after switch
|
||||
docs(readme): update install instructions
|
||||
ci: add format check workflow
|
||||
chore(deps): bump tauri to 2.8.2
|
||||
```
|
||||
|
||||
Scope should usually match the subsystem (`provider`, `mcp`, `prompt`, `skill`, `proxy`,
|
||||
`session`, `tray`, `deeplink`, `usage`, `settings`, `i18n`, `backend`, `frontend`, …).
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- **Open an issue first** for new features — drive-by feature PRs can be closed.
|
||||
- **Keep PRs small and focused.** One issue, one PR.
|
||||
- `main` is the base branch; use `feat/…` or `fix/…` branches.
|
||||
- The repo enforces "explain every line" for AI-assisted PRs — see `CONTRIBUTING.md`.
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
- **Don't bypass the service/DAO layers.** Commands must not call `rusqlite` directly,
|
||||
and components must not call `invoke` directly.
|
||||
- **Don't mutate live CLI config files outside the dedicated writer modules.** They
|
||||
guarantee atomicity and backfill semantics.
|
||||
- **Don't add fields to the Tauri IPC boundary without `#[serde(rename_all = "camelCase")]`.**
|
||||
- **Don't remove `#[serial]` from backend tests that touch HOME / env** — they'll race.
|
||||
- **Don't add a new i18n key to only one language file** — CI doesn't catch it, but users will.
|
||||
- **Don't add emojis to source files / commits / UI copy** unless the user explicitly asks.
|
||||
- **Don't create new top-level docs** (README variants, wiki pages) unless asked — prefer
|
||||
editing `docs/user-manual/` or the existing README.
|
||||
- **Don't touch `CHANGELOG.md` by hand** for routine changes — it's maintained per release.
|
||||
|
||||
## Quick References
|
||||
|
||||
- **Main app shell**: `src/App.tsx` (view routing + header)
|
||||
- **Bootstrap / providers**: `src/main.tsx`
|
||||
- **Tauri entry**: `src-tauri/src/lib.rs`
|
||||
- **Command registration**: search for `tauri::generate_handler!` in `src-tauri/src/lib.rs`
|
||||
- **DB schema + migrations**: `src-tauri/src/database/schema.rs`,
|
||||
`src-tauri/src/database/migration.rs`
|
||||
- **Per-app live config IO**: `src-tauri/src/{claude,codex,gemini,opencode,openclaw}_config.rs`
|
||||
- **Local proxy**: `src-tauri/src/proxy/` (entry `mod.rs` → `server.rs`)
|
||||
- **Frontend API layer**: `src/lib/api/*` re-exported from `src/lib/api/index.ts`
|
||||
- **Query keys & hooks**: `src/lib/query/`
|
||||
- **Test IPC mocks**: `tests/msw/tauriMocks.ts` + `tests/msw/state.ts`
|
||||
@@ -268,7 +268,6 @@ pub struct ProviderMeta {
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
/// - "gemini_chat": Gemini Chat 兼容格式,需要转换,但不注入 prompt_cache_key
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
@@ -283,9 +282,9 @@ pub struct ProviderMeta {
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints that accept it.
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during openai_chat/openai_responses conversion.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
|
||||
@@ -1544,7 +1544,7 @@ fn rewrite_claude_transform_endpoint(
|
||||
|
||||
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else if is_copilot || api_format == "gemini_chat" {
|
||||
} else if is_copilot {
|
||||
"/chat/completions"
|
||||
} else if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
@@ -1694,18 +1694,6 @@ mod tests {
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_gemini_chat_path() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&foo=bar",
|
||||
"gemini_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?foo=bar");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! ## API 格式
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **gemini_chat**: Gemini Chat 兼容格式,走 `/chat/completions`,且不注入 `prompt_cache_key`
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
@@ -28,7 +27,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"gemini_chat" => "gemini_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
@@ -43,7 +41,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
{
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"gemini_chat" => "gemini_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
@@ -69,10 +66,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai_chat" | "gemini_chat" | "openai_responses"
|
||||
)
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
@@ -91,7 +85,6 @@ pub fn transform_claude_request_for_api_format(
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
"gemini_chat" => super::transform::anthropic_to_openai(body, None),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -162,7 +155,6 @@ impl ClaudeAdapter {
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
/// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
get_claude_api_format(provider)
|
||||
@@ -423,11 +415,10 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
// - "gemini_chat": 需要 Anthropic ↔ Gemini Chat 兼容格式转换(不注入 prompt_cache_key)
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "gemini_chat" | "openai_responses"
|
||||
"openai_chat" | "openai_responses"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -735,20 +726,6 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// Gemini Chat format in meta: needs transform
|
||||
let gemini_chat_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&gemini_chat_provider));
|
||||
|
||||
// OpenAI Responses format in meta: needs transform
|
||||
let openai_responses_provider = create_provider_with_meta(
|
||||
json!({
|
||||
@@ -877,31 +854,4 @@ mod tests {
|
||||
assert!(transformed.get("input").is_some());
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_gemini_chat_omits_prompt_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
prompt_cache_key: Some("custom-cache-key".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gemini-2.5-flash",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "gemini_chat").unwrap();
|
||||
|
||||
assert_eq!(transformed["model"], "gemini-2.5-flash");
|
||||
assert!(transformed.get("messages").is_some());
|
||||
assert!(transformed.get("prompt_cache_key").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut next_content_index: u32 = 0;
|
||||
@@ -107,8 +108,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
@@ -750,4 +750,45 @@ mod tests {
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// "你好" split across two TCP chunks inside a streaming text delta.
|
||||
// Before the fix, from_utf8_lossy would produce U+FFFD for each half.
|
||||
let full = concat!(
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" in the byte stream and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split after first byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
// Must contain the original Chinese characters, not replacement chars
|
||||
assert!(
|
||||
merged.contains("你好"),
|
||||
"expected '你好' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut current_model: Option<String> = None;
|
||||
let mut has_sent_message_start = false;
|
||||
@@ -118,8 +119,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
@@ -1029,4 +1029,45 @@ mod tests {
|
||||
assert_eq!(text_stops, 1);
|
||||
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_responses_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// Chinese text delta split across two TCP chunks.
|
||||
let full = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cn\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好世界\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 2; // split after second byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(
|
||||
merged.contains("你好世界"),
|
||||
"expected '你好世界' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
}
|
||||
}
|
||||
|
||||
normalize_openai_system_messages(&mut messages);
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
@@ -182,6 +183,57 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
|
||||
.count();
|
||||
|
||||
if system_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if system_count == 1 {
|
||||
if let Some(index) = messages.iter().position(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
}) {
|
||||
if index > 0 {
|
||||
let message = messages.remove(index);
|
||||
messages.insert(0, message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match message.get("content") {
|
||||
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
|
||||
Some(Value::Array(content_parts)) => {
|
||||
let text = content_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
@@ -560,6 +612,31 @@ mod tests {
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code."},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "system", "content": "Follow repo conventions."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise.\nFollow repo conventions."
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
|
||||
@@ -71,6 +71,7 @@ impl StreamHandler {
|
||||
async_stream::stream! {
|
||||
let mut _last_activity = Instant::now();
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -82,8 +83,7 @@ impl StreamHandler {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
|
||||
@@ -568,6 +568,7 @@ pub fn create_logged_passthrough_stream(
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
@@ -619,8 +620,7 @@ pub fn create_logged_passthrough_stream(
|
||||
);
|
||||
}
|
||||
is_first_chunk = false;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
|
||||
+274
-1
@@ -4,9 +4,71 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
|
||||
/// characters that are split across chunk boundaries.
|
||||
///
|
||||
/// `remainder` accumulates trailing bytes from the previous chunk that form an
|
||||
/// incomplete UTF-8 sequence (at most 3 bytes under normal operation). On each
|
||||
/// call the remainder is prepended to `new_bytes`, the longest valid UTF-8
|
||||
/// prefix is appended to `buffer`, and any trailing incomplete bytes are saved
|
||||
/// back into `remainder` for the next call.
|
||||
///
|
||||
/// A defensive guard discards `remainder` via lossy conversion if it ever
|
||||
/// exceeds 3 bytes, which cannot happen with well-formed UTF-8 streams.
|
||||
pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new_bytes: &[u8]) {
|
||||
// Build the byte slice to decode: prepend any leftover bytes from previous chunk.
|
||||
let (owned, bytes): (Option<Vec<u8>>, &[u8]) = if remainder.is_empty() {
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
// Defensive guard: remainder should never exceed 3 bytes (max incomplete
|
||||
// UTF-8 sequence is 3 bytes: a 4-byte char missing its last byte). If it
|
||||
// does, the stream is producing genuinely invalid bytes; flush them lossy
|
||||
// and start fresh.
|
||||
if remainder.len() > 3 {
|
||||
buffer.push_str(&String::from_utf8_lossy(remainder));
|
||||
remainder.clear();
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
let mut combined = std::mem::take(remainder);
|
||||
combined.extend_from_slice(new_bytes);
|
||||
(Some(combined), &[])
|
||||
}
|
||||
};
|
||||
let input = owned.as_deref().unwrap_or(bytes);
|
||||
|
||||
// Decode loop: consume all valid UTF-8 and any genuinely invalid bytes,
|
||||
// only leaving a trailing incomplete sequence in remainder.
|
||||
let mut pos = 0;
|
||||
loop {
|
||||
match std::str::from_utf8(&input[pos..]) {
|
||||
Ok(s) => {
|
||||
buffer.push_str(s);
|
||||
// Everything consumed – remainder stays empty.
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let valid_up_to = pos + e.valid_up_to();
|
||||
buffer.push_str(
|
||||
// Safety: from_utf8 guarantees [pos..valid_up_to] is valid UTF-8.
|
||||
std::str::from_utf8(&input[pos..valid_up_to]).unwrap(),
|
||||
);
|
||||
if let Some(invalid_len) = e.error_len() {
|
||||
// Genuinely invalid byte(s) – emit U+FFFD and continue.
|
||||
buffer.push('\u{FFFD}');
|
||||
pos = valid_up_to + invalid_len;
|
||||
} else {
|
||||
// Incomplete trailing sequence – stash for next chunk.
|
||||
*remainder = input[valid_up_to..].to_vec();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_sse_field;
|
||||
use super::{append_utf8_safe, strip_sse_field};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -28,4 +90,215 @@ mod tests {
|
||||
);
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// append_utf8_safe tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello world");
|
||||
assert_eq!(buf, "hello world");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_multibyte_in_single_chunk() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, "你好世界".as_bytes());
|
||||
assert_eq!(buf, "你好世界");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_multibyte_across_two_chunks() {
|
||||
// "你" = E4 BD A0 (3 bytes)
|
||||
let bytes = "你".as_bytes();
|
||||
assert_eq!(bytes.len(), 3);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: first 2 bytes (incomplete)
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
// Chunk 2: last byte completes the character
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..]);
|
||||
assert_eq!(buf, "你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_four_byte_char_across_chunks() {
|
||||
// 😀 = F0 9F 98 80 (4 bytes)
|
||||
let bytes = "😀".as_bytes();
|
||||
assert_eq!(bytes.len(), 4);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Send 1 byte at a time
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..1]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[1..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..3]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 3);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[3..]);
|
||||
assert_eq!(buf, "😀");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_ascii_and_split_multibyte() {
|
||||
// "hi你" = 68 69 E4 BD A0
|
||||
let all = "hi你".as_bytes();
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: "hi" + first byte of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[..3]);
|
||||
assert_eq!(buf, "hi");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Chunk 2: remaining 2 bytes of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[3..]);
|
||||
assert_eq!(buf, "hi你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_split_characters_in_sequence() {
|
||||
let text = "你好";
|
||||
let bytes = text.as_bytes(); // E4 BD A0 E5 A5 BD
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Split in the middle: first char complete + 1 byte of second
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..4]);
|
||||
assert_eq!(buf, "你");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Remaining 2 bytes complete second char
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[4..]);
|
||||
assert_eq!(buf, "你好");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_chunks_are_harmless() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "");
|
||||
assert!(rem.is_empty());
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"ok");
|
||||
assert_eq!(buf, "ok");
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_json_with_chinese_split_at_boundary() {
|
||||
// Simulates an SSE data line with Chinese content split across chunks
|
||||
let json_line = "data: {\"text\":\"你好\"}\n\n";
|
||||
let bytes = json_line.as_bytes();
|
||||
|
||||
// Find where "你" starts in the byte stream and split there
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split inside "你"
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..split_point]);
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[split_point..]);
|
||||
|
||||
assert_eq!(buf, json_line);
|
||||
assert!(rem.is_empty());
|
||||
|
||||
// Verify the buffer can be parsed as SSE with valid JSON
|
||||
let data = strip_sse_field(buf.lines().next().unwrap(), "data").unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(data).unwrap();
|
||||
assert_eq!(parsed["text"], "你好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bytes_flushed_immediately_not_accumulated() {
|
||||
// 0xFF is never valid in UTF-8 – it should be replaced immediately,
|
||||
// not stashed in remainder.
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// "hi" + invalid byte + "ok"
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hi\xFFok");
|
||||
assert!(
|
||||
rem.is_empty(),
|
||||
"remainder should be empty after invalid byte"
|
||||
);
|
||||
assert!(buf.contains("hi"), "valid prefix must be present");
|
||||
assert!(buf.contains("ok"), "valid suffix must be present");
|
||||
assert!(buf.contains('\u{FFFD}'), "invalid byte must produce U+FFFD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_byte_in_slow_path_flushed_immediately() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Prime remainder with an incomplete sequence (first byte of "你")
|
||||
append_utf8_safe(&mut buf, &mut rem, &"你".as_bytes()[..1]);
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Next chunk starts with an invalid byte – the stale remainder and the
|
||||
// invalid byte should both be flushed, not accumulated.
|
||||
append_utf8_safe(&mut buf, &mut rem, b"\xFFworld");
|
||||
assert!(rem.is_empty(), "remainder should be empty");
|
||||
assert!(
|
||||
buf.contains("world"),
|
||||
"valid data after invalid byte must appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defensive_guard_flushes_oversized_remainder() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Manually inject 4 invalid bytes into remainder to trigger the >3 guard.
|
||||
// This can't happen with well-formed UTF-8, but tests the safety net.
|
||||
rem.extend_from_slice(b"\x80\x80\x80\x80");
|
||||
assert_eq!(rem.len(), 4);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello");
|
||||
// The 4 invalid bytes should have been flushed lossy, then "hello" decoded.
|
||||
assert!(rem.is_empty(), "remainder must be empty after guard flush");
|
||||
assert!(
|
||||
buf.contains("hello"),
|
||||
"valid data after guard flush must appear"
|
||||
);
|
||||
// The 4 invalid bytes each produce a U+FFFD
|
||||
let replacement_count = buf.chars().filter(|&c| c == '\u{FFFD}').count();
|
||||
assert_eq!(
|
||||
replacement_count, 4,
|
||||
"each invalid byte should produce one U+FFFD"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +309,6 @@ impl StreamCheckService {
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
/// - "gemini_chat": Gemini Chat 兼容 API (/chat/completions, 不注入 prompt_cache_key)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
@@ -345,7 +344,6 @@ impl StreamCheckService {
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let is_openai_chat = effective_api_format == "openai_chat";
|
||||
let is_gemini_chat = effective_api_format == "gemini_chat";
|
||||
let is_openai_responses = effective_api_format == "openai_responses";
|
||||
let url =
|
||||
Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
|
||||
@@ -362,9 +360,6 @@ impl StreamCheckService {
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_gemini_chat {
|
||||
anthropic_to_openai(anthropic_body, None)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_openai_chat {
|
||||
anthropic_to_openai(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
@@ -400,7 +395,7 @@ impl StreamCheckService {
|
||||
.header("x-vscode-user-agent-library-version", "electron-fetch")
|
||||
.header("x-request-id", &request_id)
|
||||
.header("x-agent-task-id", &request_id);
|
||||
} else if is_openai_chat || is_gemini_chat || is_openai_responses {
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
@@ -766,7 +761,7 @@ impl StreamCheckService {
|
||||
|
||||
if is_github_copilot && api_format == "openai_responses" {
|
||||
format!("{base}/v1/responses")
|
||||
} else if is_github_copilot || api_format == "gemini_chat" {
|
||||
} else if is_github_copilot {
|
||||
format!("{base}/chat/completions")
|
||||
} else if api_format == "openai_responses" {
|
||||
if base.ends_with("/v1") {
|
||||
@@ -960,21 +955,6 @@ mod tests {
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_chat() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
AuthStrategy::Bearer,
|
||||
"gemini_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_responses() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
|
||||
@@ -416,11 +416,9 @@ export function ClaudeFormFields({
|
||||
hint={
|
||||
apiFormat === "openai_responses"
|
||||
? t("providerForm.apiHintResponses")
|
||||
: apiFormat === "gemini_chat"
|
||||
? t("providerForm.apiHintGeminiChat")
|
||||
: apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: t("providerForm.apiHint")
|
||||
: apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
@@ -490,11 +488,6 @@ export function ClaudeFormFields({
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_chat">
|
||||
{t("providerForm.apiFormatGeminiChat", {
|
||||
defaultValue: "Gemini Chat Compatible (需开启代理)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需转换)",
|
||||
|
||||
@@ -48,9 +48,8 @@ export interface ProviderPreset {
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat" | "gemini_chat" | "openai_responses";
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
|
||||
// 供应商类型标识(用于特殊供应商检测)
|
||||
// - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证)
|
||||
|
||||
@@ -158,13 +158,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
|
||||
defaultValue: "使用 OpenAI Chat 接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "gemini_chat" &&
|
||||
activeApp === "claude"
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonGeminiChat", {
|
||||
defaultValue: "使用 Gemini Chat 兼容接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_responses" &&
|
||||
activeApp === "claude"
|
||||
@@ -214,7 +207,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
provider.category !== "official" &&
|
||||
(isCopilotProvider ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "gemini_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")
|
||||
) {
|
||||
// OpenAI format provider: show proxy hint (skip if warning already shown)
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
|
||||
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
|
||||
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
|
||||
"proxyReasonGeminiChat": "uses Gemini Chat compatible API format",
|
||||
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
||||
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
||||
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
|
||||
@@ -747,7 +746,6 @@
|
||||
"modelHint": "💡 Leave blank to use provider's default model",
|
||||
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
|
||||
"apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash",
|
||||
"apiHintGeminiChat": "💡 Fill in a Gemini Chat compatible service endpoint. For Google AI Studio, use the /v1beta/openai root and avoid a trailing slash",
|
||||
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
||||
"fillSupplierName": "Please fill in provider name",
|
||||
"fillConfigContent": "Please fill in configuration content",
|
||||
@@ -772,7 +770,6 @@
|
||||
"fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
||||
"apiFormatGeminiChat": "Gemini Chat Compatible (Requires proxy)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
|
||||
"authField": "Auth Field",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)",
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
|
||||
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
|
||||
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
|
||||
"proxyReasonGeminiChat": "Gemini Chat 互換 API フォーマットを使用しており",
|
||||
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
||||
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
||||
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
@@ -747,7 +746,6 @@
|
||||
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
|
||||
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintGeminiChat": "💡 Gemini Chat 互換サービスのエンドポイントを入力してください。Google AI Studio の場合は /v1beta/openai ルートを使い、末尾にスラッシュを付けないでください",
|
||||
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
|
||||
"fillSupplierName": "プロバイダー名を入力してください",
|
||||
"fillConfigContent": "設定内容を入力してください",
|
||||
@@ -772,7 +770,6 @@
|
||||
"fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||
"apiFormatGeminiChat": "Gemini Chat Compatible(プロキシが必要)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
|
||||
"authField": "認証フィールド",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)",
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
|
||||
"proxyReasonGeminiChat": "使用 Gemini Chat 兼容接口格式",
|
||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
||||
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
||||
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
@@ -747,7 +746,6 @@
|
||||
"modelHint": "💡 留空将使用供应商的默认模型",
|
||||
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintGeminiChat": "💡 填写兼容 Gemini Chat 的服务端点地址,例如 Google AI Studio 可填写到 /v1beta/openai,且不要以斜杠结尾",
|
||||
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
||||
"fillSupplierName": "请填写供应商名称",
|
||||
"fillConfigContent": "请填写配置内容",
|
||||
@@ -772,7 +770,6 @@
|
||||
"fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||
"apiFormatGeminiChat": "Gemini Chat Compatible (需开启代理)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
|
||||
"authField": "认证字段",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
|
||||
+3
-9
@@ -158,16 +158,15 @@ export interface ProviderMeta {
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat" | "gemini_chat" | "openai_responses";
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
// 通用认证绑定
|
||||
authBinding?: AuthBinding;
|
||||
// Claude 认证字段名
|
||||
apiKeyField?: ClaudeApiKeyField;
|
||||
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
|
||||
isFullUrl?: boolean;
|
||||
// Prompt cache key for compatible endpoints that accept it (not used by gemini_chat)
|
||||
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
|
||||
promptCacheKey?: string;
|
||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||
providerType?: string;
|
||||
@@ -181,13 +180,8 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
||||
// Claude API 格式类型
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
export type ClaudeApiFormat =
|
||||
| "anthropic"
|
||||
| "openai_chat"
|
||||
| "gemini_chat"
|
||||
| "openai_responses";
|
||||
export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses";
|
||||
|
||||
// Claude 认证字段类型
|
||||
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||
|
||||
Reference in New Issue
Block a user