From 488385e059993b007445eaa16034e230c7b3c2cb Mon Sep 17 00:00:00 2001 From: dragonfsky Date: Sat, 16 May 2026 18:40:56 +0800 Subject: [PATCH] feat(channel): add Vercel AI Gateway support --- README.md | 2 +- README.zh_CN.md | 2 +- docs/src/content/docs/guides/providers.md | 1 + .../content/docs/zh-cn/guides/providers.md | 1 + .../src/modules/admin/ProvidersModule.tsx | 1 + .../admin/providers/channel-forms.test.ts | 14 ++ .../modules/admin/providers/channel-forms.ts | 9 + sdk/README.md | 1 + sdk/README.zh_CN.md | 1 + sdk/gproxy-channel/Cargo.toml | 3 +- sdk/gproxy-channel/README.md | 11 +- sdk/gproxy-channel/src/channels/mod.rs | 2 + sdk/gproxy-channel/src/channels/vercel.rs | 202 ++++++++++++++++++ sdk/gproxy-channel/tests/routing_alignment.rs | 39 +++- sdk/gproxy-channel/tests/vercel_channel.rs | 154 +++++++++++++ sdk/gproxy-engine/Cargo.toml | 3 +- sdk/gproxy-engine/src/engine.rs | 19 ++ sdk/gproxy-engine/src/store/mod.rs | 2 + sdk/gproxy-sdk/Cargo.toml | 1 + 19 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 sdk/gproxy-channel/src/channels/vercel.rs create mode 100644 sdk/gproxy-channel/tests/vercel_channel.rs diff --git a/README.md b/README.md index f667011b..ab46d4d7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ surface on top of many upstream LLM providers, and adds the primitives you need to run it as a shared service: - **Multi-provider routing** — OpenAI, Anthropic, Vertex / Gemini, - DeepSeek, Groq, OpenRouter, NVIDIA, Claude Code, Codex, Antigravity, + DeepSeek, Groq, OpenRouter, Vercel AI Gateway, NVIDIA, Claude Code, Codex, Antigravity, and any OpenAI-compatible custom endpoint. - **Two routing modes** — aggregated `/v1/...` (provider encoded in the model name) and scoped `/{provider}/v1/...` (provider in the URL). diff --git a/README.zh_CN.md b/README.zh_CN.md index ec65a200..ff3ab8a5 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -17,7 +17,7 @@ GPROXY 在多家上游 LLM 供应商之上暴露一个统一的、**OpenAI / Ant Gemini 兼容**的 HTTP 接口,并提供把它作为共享服务运行所需的一切基础设施: - **多供应商路由** —— OpenAI、Anthropic、Vertex / Gemini、DeepSeek、 - Groq、OpenRouter、NVIDIA、Claude Code、Codex、Antigravity,以及任意 + Groq、OpenRouter、Vercel AI Gateway、NVIDIA、Claude Code、Codex、Antigravity,以及任意 OpenAI 兼容的自定义端点。 - **两种路由模式** —— 聚合 `/v1/...`(供应商名编码在 model 字段里) 和限定作用域 `/{provider}/v1/...`(供应商在 URL 里)。 diff --git a/docs/src/content/docs/guides/providers.md b/docs/src/content/docs/guides/providers.md index 6dde7f27..28157043 100644 --- a/docs/src/content/docs/guides/providers.md +++ b/docs/src/content/docs/guides/providers.md @@ -34,6 +34,7 @@ the subset you need. | `deepseek` | api.deepseek.com | OpenAI-compatible DeepSeek. | | `groq` | api.groq.com | | | `openrouter` | openrouter.ai | | +| `vercel` | Vercel AI Gateway | OpenAI-compatible models, chat completions, and embeddings with Bearer API-key credentials. | | `nvidia` | NVIDIA NIM endpoints | | | `custom` | Any OpenAI-compatible upstream | Use this for self-hosted or third-party gateways. | diff --git a/docs/src/content/docs/zh-cn/guides/providers.md b/docs/src/content/docs/zh-cn/guides/providers.md index 82c7bb5d..498abf45 100644 --- a/docs/src/content/docs/zh-cn/guides/providers.md +++ b/docs/src/content/docs/zh-cn/guides/providers.md @@ -33,6 +33,7 @@ Provider ──(channel)──► 上游协议实现 | `deepseek` | api.deepseek.com | OpenAI 兼容的 DeepSeek。 | | `groq` | api.groq.com | | | `openrouter` | openrouter.ai | | +| `vercel` | Vercel AI Gateway | OpenAI 兼容的模型、聊天补全和嵌入接口,使用 Bearer API key 凭证。 | | `nvidia` | NVIDIA NIM 端点 | | | `custom` | 任意 OpenAI 兼容上游 | 自建或第三方网关常用。 | diff --git a/frontend/console/src/modules/admin/ProvidersModule.tsx b/frontend/console/src/modules/admin/ProvidersModule.tsx index f736699d..1664ace9 100644 --- a/frontend/console/src/modules/admin/ProvidersModule.tsx +++ b/frontend/console/src/modules/admin/ProvidersModule.tsx @@ -73,6 +73,7 @@ export function ProvidersModule({ "deepseek", "groq", "openrouter", + "vercel", "custom", ].map((value) => ({ value, label: value })), [], diff --git a/frontend/console/src/modules/admin/providers/channel-forms.test.ts b/frontend/console/src/modules/admin/providers/channel-forms.test.ts index 5f12e800..a097ffb2 100644 --- a/frontend/console/src/modules/admin/providers/channel-forms.test.ts +++ b/frontend/console/src/modules/admin/providers/channel-forms.test.ts @@ -19,6 +19,20 @@ describe("buildChannelSettingsJson", () => { expect(result).toEqual({ base_url: "https://api.openai.com" }); }); + it("exposes vercel ai gateway settings and credential schema", () => { + expect(defaultSettingsForChannel("vercel")).toEqual({ + base_url: "https://ai-gateway.vercel.sh", + user_agent: "", + }); + expect(settingsFieldsForChannel("vercel").map((field) => field.key)).toContain("base_url"); + expect(credentialFieldsForChannel("vercel").map((field) => field.key)).toEqual(["api_key"]); + expect( + buildCredentialJson("vercel", { + api_key: "test-vercel-key", + }), + ).toEqual({ api_key: "test-vercel-key" }); + }); + it("exposes the full codex oauth credential schema", () => { expect(credentialFieldsForChannel("codex").map((field) => field.key)).toEqual([ "access_token", diff --git a/frontend/console/src/modules/admin/providers/channel-forms.ts b/frontend/console/src/modules/admin/providers/channel-forms.ts index c6f80135..8f68d337 100644 --- a/frontend/console/src/modules/admin/providers/channel-forms.ts +++ b/frontend/console/src/modules/admin/providers/channel-forms.ts @@ -50,6 +50,7 @@ export const ALL_CHANNEL_IDS = [ "deepseek", "groq", "openrouter", + "vercel", ] as const; /// Common settings fields appended to every channel so sanitize_rules @@ -224,6 +225,13 @@ export const SETTINGS_CHANNEL_CONFIG: Record = { { key: "user_agent", label: "user_agent", type: "text", optional: true }, ], }, + vercel: { + defaults: { base_url: "https://ai-gateway.vercel.sh", user_agent: "" }, + fields: [ + { key: "base_url", label: "base_url", type: "text" }, + { key: "user_agent", label: "user_agent", type: "text", optional: true }, + ], + }, custom: { defaults: { base_url: "", user_agent: "" }, fields: [ @@ -306,6 +314,7 @@ export const CREDENTIAL_CHANNEL_CONFIG: Record deepseek: { fields: [{ key: "api_key", label: "api_key", type: "textarea" }] }, groq: { fields: [{ key: "api_key", label: "api_key", type: "textarea" }] }, openrouter: { fields: [{ key: "api_key", label: "api_key", type: "textarea" }] }, + vercel: { fields: [{ key: "api_key", label: "api_key", type: "textarea" }] }, custom: { fields: [{ key: "api_key", label: "api_key", type: "textarea" }] }, }; diff --git a/sdk/README.md b/sdk/README.md index 8a8deeaa..e2619d7d 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -75,6 +75,7 @@ Features declared in `sdk/gproxy-sdk/Cargo.toml`: | `deepseek` | `["gproxy-provider/deepseek"]` | DeepSeek channel feature. | | `groq` | `["gproxy-provider/groq"]` | Groq channel feature. | | `openrouter` | `["gproxy-provider/openrouter"]` | OpenRouter channel feature. | +| `vercel` | `["gproxy-provider/vercel"]` | Vercel AI Gateway channel feature. | | `custom` | `["gproxy-provider/custom"]` | Custom compatibility channel feature. | | `redis` | Not declared in `[features]` of either `sdk/gproxy-sdk/Cargo.toml` or `sdk/gproxy-provider/Cargo.toml`. | The SDK layer currently has no `redis` feature flag; the workspace root has a `redis` dependency, but it is not a feature here. | diff --git a/sdk/README.zh_CN.md b/sdk/README.zh_CN.md index d7bceebc..2f587ec0 100644 --- a/sdk/README.zh_CN.md +++ b/sdk/README.zh_CN.md @@ -75,6 +75,7 @@ assert_eq!(providers.len(), 1); | `deepseek` | `["gproxy-provider/deepseek"]` | DeepSeek 渠道 feature。 | | `groq` | `["gproxy-provider/groq"]` | Groq 渠道 feature。 | | `openrouter` | `["gproxy-provider/openrouter"]` | OpenRouter 渠道 feature。 | +| `vercel` | `["gproxy-provider/vercel"]` | Vercel AI Gateway 渠道 feature。 | | `custom` | `["gproxy-provider/custom"]` | 自定义兼容渠道 feature。 | | `redis` | 未在 `sdk/gproxy-sdk/Cargo.toml` 或 `sdk/gproxy-provider/Cargo.toml` 的 `[features]` 中声明。 | 当前 SDK 层没有 `redis` feature flag;workspace 顶层存在 `redis` 依赖,但它不是这里的 feature。 | diff --git a/sdk/gproxy-channel/Cargo.toml b/sdk/gproxy-channel/Cargo.toml index ff420df6..c9163a3b 100644 --- a/sdk/gproxy-channel/Cargo.toml +++ b/sdk/gproxy-channel/Cargo.toml @@ -23,7 +23,7 @@ default = ["all-channels"] all-channels = [ "openai", "anthropic", "aistudio", "vertexexpress", "vertex", "geminicli", "claudecode", "codex", - "antigravity", "nvidia", "deepseek", "groq", "openrouter", "custom", + "antigravity", "nvidia", "deepseek", "groq", "openrouter", "vercel", "custom", "chatgpt" ] openai = [] @@ -40,6 +40,7 @@ nvidia = [] deepseek = [] groq = [] openrouter = [] +vercel = [] custom = [] [dependencies] diff --git a/sdk/gproxy-channel/README.md b/sdk/gproxy-channel/README.md index debf37e4..7deab56e 100644 --- a/sdk/gproxy-channel/README.md +++ b/sdk/gproxy-channel/README.md @@ -4,7 +4,7 @@ [![docs.rs](https://docs.rs/gproxy-channel/badge.svg)](https://docs.rs/gproxy-channel) [![license](https://img.shields.io/crates/l/gproxy-channel.svg)](https://github.com/LeenHawk/gproxy) -Single-channel LLM client layer for Rust. Provides the `Channel` trait, 14 +Single-channel LLM client layer for Rust. Provides the `Channel` trait, 15 pre-built channel implementations (OpenAI, Anthropic, Gemini, Vertex, and friends), strongly typed credential / request / response types, credential health tracking, a routing table system, and an `execute_once` single-request @@ -20,7 +20,7 @@ send → normalize_response → classify_response in one call. | Crate | Layer | What it covers | |---|---|---| | [`gproxy-protocol`] | L0 | Wire types + cross-protocol transforms | -| `gproxy-channel` (this crate) | L1 | `Channel` trait + 14 channels + `execute_once` | +| `gproxy-channel` (this crate) | L1 | `Channel` trait + 15 channels + `execute_once` | | [`gproxy-engine`] | L2 | Multi-channel `GproxyEngine`, provider store, retry, affinity | | [`gproxy-sdk`] | facade | Re-exports the three layers under canonical names | @@ -36,7 +36,7 @@ compiled binary. `openai`, `anthropic`, `aistudio`, `vertex`, `vertexexpress`, `geminicli`, `claudecode`, `codex`, `antigravity`, `nvidia`, `deepseek`, `groq`, -`openrouter`, `custom` +`openrouter`, `vercel`, `custom` Example — only the OpenAI channel compiles in, nothing else: @@ -91,11 +91,11 @@ version you can drive against real OpenAI. - **`Channel` trait** — implement once per upstream provider. Declares the channel's routing table, HTTP request construction, response classification, OAuth flow (if any), and optional local routes. -- **14 built-in channels** — OpenAI (`/v1/chat/completions` and +- **15 built-in channels** — OpenAI (`/v1/chat/completions` and `/v1/responses`), Anthropic Claude, Google AI Studio (Gemini), Vertex (service-account JWT + Vertex Express API-key), Gemini CLI (OAuth), Claude Code (session cookie), Codex, Antigravity, NVIDIA, DeepSeek, - Groq, OpenRouter, and a generic `custom` channel. + Groq, OpenRouter, Vercel AI Gateway, and a generic `custom` channel. - **Credential types** — `ChannelCredential` trait + per-channel concrete types (API keys, OAuth token bundles, cookie sessions, GCP service accounts). @@ -134,6 +134,7 @@ version you can drive against real OpenAI. | `deepseek` | via `all-channels` | DeepSeek | | `groq` | via `all-channels` | Groq | | `openrouter` | via `all-channels` | OpenRouter | +| `vercel` | via `all-channels` | Vercel AI Gateway | | `custom` | via `all-channels` | Generic OpenAI-compatible channel | ## License diff --git a/sdk/gproxy-channel/src/channels/mod.rs b/sdk/gproxy-channel/src/channels/mod.rs index fccd1294..6d99a239 100644 --- a/sdk/gproxy-channel/src/channels/mod.rs +++ b/sdk/gproxy-channel/src/channels/mod.rs @@ -24,6 +24,8 @@ pub mod nvidia; pub mod openai; #[cfg(feature = "openrouter")] pub mod openrouter; +#[cfg(feature = "vercel")] +pub mod vercel; #[cfg(feature = "vertex")] pub mod vertex; #[cfg(feature = "vertexexpress")] diff --git a/sdk/gproxy-channel/src/channels/vercel.rs b/sdk/gproxy-channel/src/channels/vercel.rs new file mode 100644 index 00000000..eab3ce0d --- /dev/null +++ b/sdk/gproxy-channel/src/channels/vercel.rs @@ -0,0 +1,202 @@ +use serde::{Deserialize, Serialize}; + +use crate::channel::{Channel, ChannelCredential, ChannelSettings, CommonChannelSettings}; +use crate::health::ModelCooldownHealth; +use crate::registry::ChannelRegistration; +use crate::request::PreparedRequest; +use crate::response::{ResponseClassification, UpstreamError}; +use crate::routing::{RouteImplementation, RouteKey, RoutingTable}; +use gproxy_protocol::kinds::{OperationFamily, ProtocolKind}; + +/// Vercel AI Gateway channel. +/// +/// The gateway exposes OpenAI-compatible models, chat completions, and +/// embeddings endpoints with Bearer credentials. +pub struct VercelChannel; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VercelSettings { + #[serde(default = "default_vercel_base_url")] + pub base_url: String, + #[serde(flatten)] + pub common: CommonChannelSettings, +} + +impl Default for VercelSettings { + fn default() -> Self { + Self { + base_url: default_vercel_base_url(), + common: CommonChannelSettings::default(), + } + } +} + +fn default_vercel_base_url() -> String { + "https://ai-gateway.vercel.sh".to_string() +} + +impl ChannelSettings for VercelSettings { + fn base_url(&self) -> &str { + &self.base_url + } + + fn common(&self) -> Option<&CommonChannelSettings> { + Some(&self.common) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VercelCredential { + pub api_key: String, +} + +impl ChannelCredential for VercelCredential {} + +impl Channel for VercelChannel { + const ID: &'static str = "vercel"; + type Settings = VercelSettings; + type Credential = VercelCredential; + type Health = ModelCooldownHealth; + + fn routing_table(&self) -> RoutingTable { + let mut t = RoutingTable::new(); + let pass = |op: OperationFamily, proto: ProtocolKind| { + (RouteKey::new(op, proto), RouteImplementation::Passthrough) + }; + + for key in [ + pass(OperationFamily::ModelList, ProtocolKind::OpenAi), + pass(OperationFamily::ModelGet, ProtocolKind::OpenAi), + pass( + OperationFamily::GenerateContent, + ProtocolKind::OpenAiChatCompletion, + ), + pass( + OperationFamily::StreamGenerateContent, + ProtocolKind::OpenAiChatCompletion, + ), + pass(OperationFamily::Embedding, ProtocolKind::OpenAi), + ] { + t.set(key.0, key.1); + } + + t + } + + fn prepare_request( + &self, + credential: &Self::Credential, + settings: &Self::Settings, + request: &PreparedRequest, + ) -> Result>, UpstreamError> { + let mut url = format!("{}{}", settings.base_url(), vercel_request_path(request)?); + crate::utils::url::append_query(&mut url, request.query.as_deref()); + + let mut builder = http::Request::builder() + .method(request.method.clone()) + .uri(&url) + .header("Authorization", format!("Bearer {}", credential.api_key)) + .header("Content-Type", "application/json"); + + if let Some(ua) = settings.user_agent() { + builder = builder.header("User-Agent", ua); + } + + for (key, value) in request.headers.iter() { + builder = builder.header(key, value); + } + crate::utils::http_headers::replace_header( + &mut builder, + "Authorization", + format!("Bearer {}", credential.api_key), + )?; + crate::utils::http_headers::replace_header( + &mut builder, + "Content-Type", + "application/json", + )?; + if let Some(ua) = settings.user_agent() { + crate::utils::http_headers::replace_header(&mut builder, "User-Agent", ua)?; + } + + builder + .body(request.body.clone()) + .map_err(|e| UpstreamError::RequestBuild(e.to_string())) + } + + fn prepare_quota_request( + &self, + credential: &Self::Credential, + settings: &Self::Settings, + ) -> Result>>, UpstreamError> { + let url = format!("{}/v1/credits", settings.base_url().trim_end_matches('/')); + let mut builder = http::Request::builder() + .method(http::Method::GET) + .uri(&url) + .header("Authorization", format!("Bearer {}", credential.api_key)) + .header("Accept", "application/json"); + + if let Some(ua) = settings.user_agent() { + builder = builder.header("User-Agent", ua); + } + + builder + .body(Vec::new()) + .map(Some) + .map_err(|e| UpstreamError::RequestBuild(e.to_string())) + } + + fn classify_response( + &self, + status: u16, + headers: &http::HeaderMap, + _body: &[u8], + ) -> ResponseClassification { + match status { + 200..=299 => ResponseClassification::Success, + 401 | 403 => ResponseClassification::AuthDead, + 429 => { + let retry_after = headers + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .map(|secs| secs * 1000); + ResponseClassification::RateLimited { + retry_after_ms: retry_after, + } + } + 500..=599 => ResponseClassification::TransientError, + _ => ResponseClassification::PermanentError, + } + } +} + +fn vercel_request_path(request: &PreparedRequest) -> Result { + match request.route.operation { + OperationFamily::ModelList => Ok("/v1/models".to_string()), + OperationFamily::ModelGet => Ok(format!( + "/v1/models/{}", + request.model.as_deref().unwrap_or_default() + )), + OperationFamily::GenerateContent | OperationFamily::StreamGenerateContent => { + match request.route.protocol { + ProtocolKind::OpenAiChatCompletion => Ok("/v1/chat/completions".to_string()), + _ => Err(UpstreamError::Channel(format!( + "unsupported vercel generate route protocol: {}", + request.route.protocol + ))), + } + } + OperationFamily::Embedding => Ok("/v1/embeddings".to_string()), + _ => Err(UpstreamError::Channel(format!( + "unsupported vercel request route: ({}, {})", + request.route.operation, request.route.protocol + ))), + } +} + +fn vercel_routing_table() -> RoutingTable { + VercelChannel.routing_table() +} + +inventory::submit! { ChannelRegistration::new(VercelChannel::ID, vercel_routing_table) } diff --git a/sdk/gproxy-channel/tests/routing_alignment.rs b/sdk/gproxy-channel/tests/routing_alignment.rs index 399ebd4c..683b008c 100644 --- a/sdk/gproxy-channel/tests/routing_alignment.rs +++ b/sdk/gproxy-channel/tests/routing_alignment.rs @@ -3,7 +3,8 @@ use gproxy_channel::channels::{ aistudio::AiStudioChannel, anthropic::AnthropicChannel, antigravity::AntigravityChannel, claudecode::ClaudeCodeChannel, codex::CodexChannel, deepseek::DeepSeekChannel, geminicli::GeminiCliChannel, groq::GroqChannel, nvidia::NvidiaChannel, - openrouter::OpenRouterChannel, vertex::VertexChannel, vertexexpress::VertexExpressChannel, + openrouter::OpenRouterChannel, vercel::VercelChannel, vertex::VertexChannel, + vertexexpress::VertexExpressChannel, }; use gproxy_channel::routing::{RouteImplementation, RouteKey}; use gproxy_protocol::kinds::{OperationFamily, ProtocolKind}; @@ -185,3 +186,39 @@ fn codex_groq_nvidia_and_deepseek_use_local_count_tokens() { assert_local(&table, OperationFamily::CountToken, ProtocolKind::Gemini); } } + +#[test] +fn vercel_routes_documented_openai_compatible_endpoints() { + let table = VercelChannel.routing_table(); + + assert_passthrough( + &table, + OperationFamily::GenerateContent, + ProtocolKind::OpenAiChatCompletion, + ); + assert_passthrough( + &table, + OperationFamily::StreamGenerateContent, + ProtocolKind::OpenAiChatCompletion, + ); + assert_passthrough(&table, OperationFamily::ModelList, ProtocolKind::OpenAi); + assert_passthrough(&table, OperationFamily::ModelGet, ProtocolKind::OpenAi); + assert_passthrough(&table, OperationFamily::Embedding, ProtocolKind::OpenAi); + + assert!( + table + .resolve(&RouteKey::new( + OperationFamily::GenerateContent, + ProtocolKind::OpenAiResponse, + )) + .is_none() + ); + assert!( + table + .resolve(&RouteKey::new( + OperationFamily::CountToken, + ProtocolKind::OpenAi, + )) + .is_none() + ); +} diff --git a/sdk/gproxy-channel/tests/vercel_channel.rs b/sdk/gproxy-channel/tests/vercel_channel.rs new file mode 100644 index 00000000..e1d42c31 --- /dev/null +++ b/sdk/gproxy-channel/tests/vercel_channel.rs @@ -0,0 +1,154 @@ +#![cfg(feature = "vercel")] + +use gproxy_channel::channel::Channel; +use gproxy_channel::channels::vercel::{VercelChannel, VercelCredential, VercelSettings}; +use gproxy_channel::request::PreparedRequest; +use gproxy_channel::response::ResponseClassification; +use gproxy_channel::routing::RouteKey; +use gproxy_protocol::kinds::{OperationFamily, ProtocolKind}; + +fn prepared_request(operation: OperationFamily, protocol: ProtocolKind) -> PreparedRequest { + PreparedRequest { + method: http::Method::POST, + route: RouteKey::new(operation, protocol), + model: Some("anthropic/claude-sonnet-4".to_string()), + query: None, + body: br#"{"model":"anthropic/claude-sonnet-4","messages":[]}"#.to_vec(), + headers: http::HeaderMap::new(), + } +} + +#[test] +fn vercel_defaults_to_ai_gateway_base_url_and_bearer_auth() { + let settings = VercelSettings::default(); + let credential = VercelCredential { + api_key: "test-vercel-key".to_string(), + }; + let request = prepared_request( + OperationFamily::GenerateContent, + ProtocolKind::OpenAiChatCompletion, + ); + + let upstream = VercelChannel + .prepare_request(&credential, &settings, &request) + .expect("prepare request"); + + assert_eq!( + upstream.uri().to_string(), + "https://ai-gateway.vercel.sh/v1/chat/completions" + ); + assert_eq!( + upstream + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()), + Some("Bearer test-vercel-key") + ); +} + +#[test] +fn vercel_maps_models_and_embeddings_to_documented_openai_compatible_paths() { + let settings = VercelSettings::default(); + let credential = VercelCredential { + api_key: "test-vercel-key".to_string(), + }; + + let models = VercelChannel + .prepare_request( + &credential, + &settings, + &PreparedRequest { + method: http::Method::GET, + route: RouteKey::new(OperationFamily::ModelList, ProtocolKind::OpenAi), + model: None, + query: None, + body: Vec::new(), + headers: http::HeaderMap::new(), + }, + ) + .expect("model list request"); + assert_eq!( + models.uri().to_string(), + "https://ai-gateway.vercel.sh/v1/models" + ); + + let embeddings = VercelChannel + .prepare_request( + &credential, + &settings, + &prepared_request(OperationFamily::Embedding, ProtocolKind::OpenAi), + ) + .expect("embedding request"); + assert_eq!( + embeddings.uri().to_string(), + "https://ai-gateway.vercel.sh/v1/embeddings" + ); +} + +#[test] +fn vercel_rejects_unsupported_openai_responses_and_count_token_routes() { + let settings = VercelSettings::default(); + let credential = VercelCredential { + api_key: "test-vercel-key".to_string(), + }; + let responses_request = prepared_request( + OperationFamily::GenerateContent, + ProtocolKind::OpenAiResponse, + ); + let count_request = prepared_request(OperationFamily::CountToken, ProtocolKind::OpenAi); + + assert!( + VercelChannel + .prepare_request(&credential, &settings, &responses_request) + .is_err() + ); + assert!( + VercelChannel + .prepare_request(&credential, &settings, &count_request) + .is_err() + ); +} + +#[test] +fn vercel_query_quota_uses_ai_gateway_usage_endpoint() { + let settings = VercelSettings::default(); + let credential = VercelCredential { + api_key: "test-vercel-key".to_string(), + }; + + let request = VercelChannel + .prepare_quota_request(&credential, &settings) + .expect("quota request") + .expect("vercel supports quota requests"); + + assert_eq!( + request.uri().to_string(), + "https://ai-gateway.vercel.sh/v1/credits" + ); + assert_eq!(request.method(), http::Method::GET); + assert_eq!( + request + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()), + Some("Bearer test-vercel-key") + ); +} + +#[test] +fn vercel_classifies_auth_and_rate_limit_responses() { + let headers = http::HeaderMap::new(); + + assert!(matches!( + VercelChannel.classify_response(401, &headers, b""), + ResponseClassification::AuthDead + )); + assert!(matches!( + VercelChannel.classify_response(403, &headers, b""), + ResponseClassification::AuthDead + )); + assert!(matches!( + VercelChannel.classify_response(429, &headers, b""), + ResponseClassification::RateLimited { .. } + )); +} diff --git a/sdk/gproxy-engine/Cargo.toml b/sdk/gproxy-engine/Cargo.toml index d3769577..76d8fdd8 100644 --- a/sdk/gproxy-engine/Cargo.toml +++ b/sdk/gproxy-engine/Cargo.toml @@ -17,7 +17,7 @@ all-channels = [ "gproxy-channel/all-channels", "openai", "anthropic", "aistudio", "vertexexpress", "vertex", "geminicli", "claudecode", "codex", "chatgpt", - "antigravity", "nvidia", "deepseek", "groq", "openrouter", "custom" + "antigravity", "nvidia", "deepseek", "groq", "openrouter", "vercel", "custom" ] openai = ["gproxy-channel/openai"] anthropic = ["gproxy-channel/anthropic"] @@ -33,6 +33,7 @@ nvidia = ["gproxy-channel/nvidia"] deepseek = ["gproxy-channel/deepseek"] groq = ["gproxy-channel/groq"] openrouter = ["gproxy-channel/openrouter"] +vercel = ["gproxy-channel/vercel"] custom = ["gproxy-channel/custom"] [dependencies] diff --git a/sdk/gproxy-engine/src/engine.rs b/sdk/gproxy-engine/src/engine.rs index a390d4a6..a93f4154 100644 --- a/sdk/gproxy-engine/src/engine.rs +++ b/sdk/gproxy-engine/src/engine.rs @@ -353,6 +353,8 @@ pub fn built_in_model_prices(channel: &str) -> Option groq::GroqChannel.model_pricing(), #[cfg(feature = "openrouter")] "openrouter" => openrouter::OpenRouterChannel.model_pricing(), + #[cfg(feature = "vercel")] + "vercel" => vercel::VercelChannel.model_pricing(), #[cfg(feature = "custom")] "custom" => custom::CustomChannel.model_pricing(), _ => return None, @@ -408,6 +410,8 @@ pub fn validate_credential_json( "groq" => validate!(groq::GroqCredential), #[cfg(feature = "openrouter")] "openrouter" => validate!(openrouter::OpenRouterCredential), + #[cfg(feature = "vercel")] + "vercel" => validate!(vercel::VercelCredential), #[cfg(feature = "custom")] "custom" => validate!(custom::CustomCredential), _ => Err(UpstreamError::Channel(format!( @@ -647,6 +651,8 @@ impl GproxyEngineBuilder { "groq" => add!(self, groq::GroqChannel, config), #[cfg(feature = "openrouter")] "openrouter" => add!(self, openrouter::OpenRouterChannel, config), + #[cfg(feature = "vercel")] + "vercel" => add!(self, vercel::VercelChannel, config), #[cfg(feature = "custom")] "custom" => add!(self, custom::CustomChannel, config), _ => Err(UpstreamError::Channel(format!( @@ -2364,6 +2370,19 @@ mod tests { assert!(err.to_string().contains("invalid credential")); } + #[test] + fn validate_credential_json_accepts_valid_vercel_credential() { + let credential = json!({ "api_key": "vck-test" }); + assert!(validate_credential_json("vercel", &credential).is_ok()); + } + + #[test] + fn validate_credential_json_rejects_invalid_vercel_credential() { + let credential = json!({ "token": "vck-test" }); + let err = validate_credential_json("vercel", &credential).unwrap_err(); + assert!(err.to_string().contains("invalid credential")); + } + #[test] fn rewrite_model_rewrites_top_level_json() { let mut body = diff --git a/sdk/gproxy-engine/src/store/mod.rs b/sdk/gproxy-engine/src/store/mod.rs index adfa658f..db3edfaf 100644 --- a/sdk/gproxy-engine/src/store/mod.rs +++ b/sdk/gproxy-engine/src/store/mod.rs @@ -196,6 +196,8 @@ impl ProviderStore { "groq" => add!(self, groq::GroqChannel, config), #[cfg(feature = "openrouter")] "openrouter" => add!(self, openrouter::OpenRouterChannel, config), + #[cfg(feature = "vercel")] + "vercel" => add!(self, vercel::VercelChannel, config), #[cfg(feature = "custom")] "custom" => add!(self, custom::CustomChannel, config), _ => Err(UpstreamError::Channel(format!( diff --git a/sdk/gproxy-sdk/Cargo.toml b/sdk/gproxy-sdk/Cargo.toml index 1bac743a..86eca518 100644 --- a/sdk/gproxy-sdk/Cargo.toml +++ b/sdk/gproxy-sdk/Cargo.toml @@ -27,6 +27,7 @@ nvidia = ["gproxy-channel/nvidia", "gproxy-engine/nvidia"] deepseek = ["gproxy-channel/deepseek", "gproxy-engine/deepseek"] groq = ["gproxy-channel/groq", "gproxy-engine/groq"] openrouter = ["gproxy-channel/openrouter", "gproxy-engine/openrouter"] +vercel = ["gproxy-channel/vercel", "gproxy-engine/vercel"] custom = ["gproxy-channel/custom", "gproxy-engine/custom"] [dependencies]