Skip to content
Closed
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 里)。
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 兼容上游 | 自建或第三方网关常用。 |

Expand Down
1 change: 1 addition & 0 deletions frontend/console/src/modules/admin/ProvidersModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function ProvidersModule({
"deepseek",
"groq",
"openrouter",
"vercel",
"custom",
].map((value) => ({ value, label: value })),
[],
Expand Down
14 changes: 14 additions & 0 deletions frontend/console/src/modules/admin/providers/channel-forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions frontend/console/src/modules/admin/providers/channel-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,6 +225,13 @@ export const SETTINGS_CHANNEL_CONFIG: Record<string, ChannelSettingsConfig> = {
{ 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: [
Expand Down Expand Up @@ -306,6 +314,7 @@ export const CREDENTIAL_CHANNEL_CONFIG: Record<string, ChannelCredentialConfig>
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" }] },
};

Expand Down
1 change: 1 addition & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
1 change: 1 addition & 0 deletions sdk/README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。 |

Expand Down
3 changes: 2 additions & 1 deletion sdk/gproxy-channel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -40,6 +40,7 @@ nvidia = []
deepseek = []
groq = []
openrouter = []
vercel = []
custom = []

[dependencies]
Expand Down
11 changes: 6 additions & 5 deletions sdk/gproxy-channel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand All @@ -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:

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sdk/gproxy-channel/src/channels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
202 changes: 202 additions & 0 deletions sdk/gproxy-channel/src/channels/vercel.rs
Original file line number Diff line number Diff line change
@@ -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<http::Request<Vec<u8>>, 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<Option<http::Request<Vec<u8>>>, 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::<u64>().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<String, UpstreamError> {
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) }
Loading