feat: 请求体规则扩展(append/insert/regex_replace)、OAuth 代理节点支持与列表分页

- 请求体规则新增 append、insert、regex_replace 三种操作,路径语法支持数组索引
- OAuth 授权/导入/批量导入支持指定代理节点(proxy_node_id),Key 级代理避免 IP 污染
- 密钥列表、模型映射、模型列表添加智能分页(useSmartPagination)
- AdvancedGuide 新增请求体规则使用指南与示例
- 简化 Codex enrich_codex 实现,README 添加 QQ 群二维码
This commit is contained in:
fawney19
2026-02-09 02:56:21 +08:00
parent 2f8af7c96b
commit cfa768eebd
18 changed files with 1660 additions and 140 deletions

View File

@@ -198,6 +198,8 @@ docker compose up -d app
<p align="center"> <p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码"> <img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
&nbsp;&nbsp;&nbsp;&nbsp;
<img src="docs/author/qrcode_1770574997172.jpg" width="200" alt="QQ群二维码">
</p> </p>
## Star History ## Star History

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

View File

@@ -0,0 +1,262 @@
# Aether 请求体规则 (Body Rules) — AI 辅助指南
> 本文档面向 AI 助手。用户在「端点管理 → 请求规则 → + 请求体」中添加规则AI 需要指导用户在 UI 表单的各个输入框中填写什么内容。
## 系统背景
Aether 是一个 AI API 网关,将用户请求转发给上游 ProviderOpenAI、Claude、Gemini 等)。请求体规则在转发前修改请求体 JSON每个 Endpoint 可配置多条规则。
## 用户请求体长什么样
规则操作的对象是用户发给 Aether 的 JSON 请求体。根据 Endpoint 的 API 格式不同,结构也不同:
### OpenAI Chat 格式(`openai:chat`
```json
{
"model": "gpt-4",
"messages": [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "你好"}
],
"temperature": 0.7,
"max_tokens": 1000,
"stream": true,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"stop": ["\n"],
"tools": [...],
"tool_choice": "auto",
"response_format": {"type": "json_object"},
"metadata": {...}
}
```
### Claude Chat 格式(`claude:chat`
```json
{
"model": "claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "你好"}
],
"system": "你是一个助手",
"max_tokens": 1024,
"temperature": 0.7,
"stream": true,
"top_p": 1,
"top_k": 40,
"stop_sequences": ["###"],
"metadata": {"user_id": "xxx"}
}
```
### Claude CLI 格式(`claude:cli`
```json
{
"model": "claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "帮我重构这段代码"}
],
"system": "你是一个编程助手",
"max_tokens": 16384,
"stream": true
}
```
### Gemini Chat 格式(`gemini:chat`
```json
{
"contents": [
{"role": "user", "parts": [{"text": "你好"}]}
],
"generationConfig": {
"temperature": 0.7,
"maxOutputTokens": 1024,
"topP": 0.9,
"topK": 40
},
"systemInstruction": {"parts": [{"text": "你是一个助手"}]},
"safetySettings": [...]
}
```
> **注意:** `model` 和 `stream` 是受保护字段,任何规则都无法修改它们。
---
## UI 表单说明
用户在端点管理对话框中点击「+ 请求体」添加规则。每条规则有一个下拉框选择操作类型,然后根据类型显示不同的输入框。
---
### 覆写set
**UI 布局:** `[覆写 ▼]` `[字段路径]` `=` `[值]` `[✓]`
| 输入框 | 填什么 | 示例 |
|--------|--------|------|
| 字段路径 | 要设置的字段,用 `.` 分隔层级,`[N]` 访问数组 | `temperature` |
| 值 | **JSON 格式**的值。字符串要加引号,数字直接写 | `0.7` |
值输入框右边有验证图标:绿色勾=JSON合法红色叉=格式错误。
**用户常见需求 → 怎么填:**
| 用户说 | 字段路径 | 值 |
|--------|---------|-----|
| "固定 temperature 为 0.3" | `temperature` | `0.3` |
| "限制最大输出 500 token" | `max_tokens` | `500` |
| "加一个 metadata 字段标记来源" | `metadata.source` | `"my-app"` |
| "设置 top_p 为 0.9" | `top_p` | `0.9` |
| "添加停止序列" | `stop` | `["\n", "###"]` |
| "设置响应格式为 JSON" | `response_format` | `{"type": "json_object"}` |
| "把第一条消息内容改掉" | `messages[0].content` | `"新的内容"` |
| "设置一个嵌套对象" | `metadata.tracking` | `{"id": "abc", "env": "prod"}` |
| "设置值为空" | `some_field` | `null` |
> **注意:** 字符串值必须加引号!`"hello"` 是字符串,`hello` 是无效 JSON。数字、布尔、null、数组、对象不需要额外引号。
---
### 删除drop
**UI 布局:** `[删除 ▼]` `[要删除的字段路径]`
| 输入框 | 填什么 | 示例 |
|--------|--------|------|
| 字段路径 | 要删除的字段路径 | `user_info.ip_address` |
**用户常见需求 → 怎么填:**
| 用户说 | 字段路径 |
|--------|---------|
| "去掉 user 字段" | `user` |
| "删除 metadata 里的 internal_flag" | `metadata.internal_flag` |
| "移除第一条消息" | `messages[0]` |
| "去掉 frequency_penalty" | `frequency_penalty` |
> **注意:** 删除数组元素会导致后续索引前移。如需删除多个数组元素,从后往前删。
---
### 重命名rename
**UI 布局:** `[重命名 ▼]` `[原路径]` `→` `[新路径]`
| 输入框 | 填什么 | 示例 |
|--------|--------|------|
| 原路径 | 源字段的路径 | `extra.trace_id` |
| 新路径 | 目标字段的路径(中间层级会自动创建) | `metadata.request_id` |
**用户常见需求 → 怎么填:**
| 用户说 | 原路径 | 新路径 |
|--------|--------|--------|
| "把 max_tokens 改名为 max_completion_tokens" | `max_tokens` | `max_completion_tokens` |
| "把 extra 里的 id 移到 metadata 下" | `extra.id` | `metadata.id` |
---
### 插入insert
**UI 布局:** `[插入 ▼]` `[数组路径]` `[位置]` `[值(JSON)]` `[✓]`
| 输入框 | 填什么 | 示例 |
|--------|--------|------|
| 数组路径 | 目标数组的路径(必须是已有的数组) | `messages` |
| 位置 | 插入位置的数字索引,**留空=追加到末尾** | `0`(开头)或留空(末尾) |
| 值 | JSON 格式的元素 | `{"role": "system", "content": "..."}` |
**用户常见需求 → 怎么填:**
| 用户说 | 数组路径 | 位置 | 值 |
|--------|---------|------|-----|
| "在开头加一条 system 消息" | `messages` | `0` | `{"role": "system", "content": "你是一个专业助手"}` |
| "在末尾追加一条消息" | `messages` | (留空) | `{"role": "user", "content": "请用中文回答"}` |
| "在第二条消息前插入" | `messages` | `1` | `{"role": "assistant", "content": "好的"}` |
> **位置说明:** `0`=最前面,`1`=第二个位置,`-1`=倒数第一个前面。留空=追加到最后。
---
### 正则替换regex_replace
**UI 布局:** `[正则替换 ▼]` `[字段路径]` `[正则]` `→` `[替换为]` `[ims]` `[✓]`
| 输入框 | 填什么 | 示例 |
|--------|--------|------|
| 字段路径 | 目标字符串字段的路径 | `messages[-1].content` |
| 正则 | 正则表达式(**不需要**加 `/` 包裹) | `1[3-9]\d{9}` |
| 替换为 | 替换成什么,留空=删除匹配内容 | `[手机号已隐藏]` |
| ims | 可选标志,留空=默认 | `i` |
> **重要:** 正则输入框里直接写正则语法即可,**不需要** JSON 转义。`\d` 就写 `\d`,不用写 `\\d`。JSON 转义是保存时系统自动处理的。
**flags 含义:**
| 字母 | 作用 | 什么时候用 |
|------|------|-----------|
| `i` | 忽略大小写 | 匹配 `hello`/`Hello`/`HELLO` |
| `m` | 多行模式 | `^`/`$` 匹配每行而非整个字符串 |
| `s` | dotall | `.` 能匹配换行符 |
大多数情况留空即可。
**用户常见需求 → 怎么填:**
| 用户说 | 字段路径 | 正则 | 替换为 | flags |
|--------|---------|------|--------|-------|
| "隐藏最后一条消息里的手机号" | `messages[-1].content` | `1[3-9]\d{9}` | `[手机号已隐藏]` | |
| "隐藏邮箱地址" | `messages[-1].content` | `[\w.+-]+@[\w.-]+\.\w{2,}` | `[邮箱已隐藏]` | `i` |
| "去掉 HTML 标签" | `messages[-1].content` | `<[^>]+>` | (留空) | |
| "把所有的 foo 替换成 bar" | `messages[-1].content` | `\bfoo\b` | `bar` | |
| "删掉 Markdown 加粗标记" | `messages[-1].content` | `\*\*([^*]+)\*\*` | `\1` | |
| "隐藏身份证号" | `messages[-1].content` | `\d{17}[\dXx]` | `[身份证已隐藏]` | |
| "隐藏 IP 地址" | `messages[-1].content` | `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` | `[IP已隐藏]` | |
---
## 路径语法速查
| 写法 | 含义 |
|------|------|
| `temperature` | 顶层字段 |
| `metadata.source` | 嵌套字段 |
| `messages[0]` | 数组第一个元素 |
| `messages[-1]` | 数组最后一个元素 |
| `messages[0].content` | 第一条消息的 content |
| `messages[-1].content` | 最后一条消息的 content |
| `data[0].items[2]` | 嵌套数组访问 |
| `config\.v1.enabled` | key 名字里有点号时用 `\.` 转义 |
---
## 多条规则示例
用户说:"帮我配置规则:在开头插入一条 system 消息说'用中文回答',固定 temperature 为 0.3,把用户消息里的手机号脱敏"
应指导用户添加 3 条规则:
| # | 操作 | 字段 1 | 字段 2 | 字段 3 | 字段 4 |
|---|------|--------|--------|--------|--------|
| 1 | 插入 | 路径: `messages` | 位置: `0` | 值: `{"role": "system", "content": "请用中文回答所有问题"}` | |
| 2 | 覆写 | 路径: `temperature` | 值: `0.3` | | |
| 3 | 正则替换 | 路径: `messages[-1].content` | 正则: `1[3-9]\d{9}` | 替换为: `[手机号]` | flags: (留空) |
规则按从上到下的顺序执行。
---
## 注意事项
1. **`model``stream` 不可修改** — 这两个字段由系统管理,写了规则也会被跳过
2. **值必须是合法 JSON** — 覆写/插入的值输入框要求 JSON 格式:字符串加引号 `"text"`,数字直接写 `123`,布尔 `true`/`false`
3. **路径必须指向正确类型** — 插入的路径必须是数组,正则替换的路径必须是字符串
4. **路径不存在时的行为** — 覆写会自动创建中间层级dict其他操作遇到不存在的路径会静默跳过
5. **正则在 UI 里直接写** — 不需要 JSON 转义,`\d` 就是 `\d`
6. **正则保存时校验** — 无效的正则表达式会在保存时报错,不会静默通过

View File

@@ -208,10 +208,12 @@ export interface BatchImportResult {
export async function batchImportOAuth( export async function batchImportOAuth(
providerId: string, providerId: string,
credentials: string credentials: string,
proxyNodeId?: string
): Promise<BatchImportResult> { ): Promise<BatchImportResult> {
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, { const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
credentials, credentials,
proxy_node_id: proxyNodeId || undefined,
}) })
return response.data return response.data
} }

View File

@@ -10,6 +10,7 @@ export interface ProviderOAuthStartResponse {
export interface ProviderOAuthCompleteRequest { export interface ProviderOAuthCompleteRequest {
callback_url: string callback_url: string
name?: string name?: string
proxy_node_id?: string
} }
export interface ProviderOAuthCompleteResponse { export interface ProviderOAuthCompleteResponse {
@@ -49,7 +50,7 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken( export async function importProviderRefreshToken(
providerId: string, providerId: string,
data: { refresh_token: string; name?: string } data: { refresh_token: string; name?: string; proxy_node_id?: string }
): Promise<ProviderOAuthCompleteResponseWithKey> { ): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data return resp.data

View File

@@ -159,7 +159,51 @@ export interface BodyRuleRename {
to: string to: string
} }
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename /**
* 请求体规则 - 向数组追加元素
*
* - path 指向目标数组,如 "messages"
* - value 为要追加的元素
*/
export interface BodyRuleAppend {
action: 'append'
path: string
value: any
}
/**
* 请求体规则 - 在数组指定位置插入元素
*
* - path 指向目标数组,如 "messages"
* - index 为插入位置(支持负数)
* - value 为要插入的元素
*/
export interface BodyRuleInsert {
action: 'insert'
path: string
index: number
value: any
}
/**
* 请求体规则 - 正则替换字符串值
*
* - path 指向目标字符串字段,如 "messages[0].content"
* - pattern 为正则表达式
* - replacement 为替换字符串
* - flags 可选,支持 i(忽略大小写)/m(多行)/s(dotall)
* - count 替换次数0=全部替换(默认)
*/
export interface BodyRuleRegexReplace {
action: 'regex_replace'
path: string
pattern: string
replacement: string
flags?: string
count?: number
}
export type BodyRule = BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace
/** /**
* 格式接受策略配置 * 格式接受策略配置

View File

@@ -0,0 +1,92 @@
import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue'
/**
* 智能分页 composable
*
* 根据列表容器的实际渲染高度自动决定是否分页以及每页条数。
* 当列表总高度超过阈值时自动启用分页,低于阈值时恢复全量显示。
*
* @param items 响应式数据源(全量列表)
* @param listRef 列表容器 DOM 引用
* @param maxHeight 触发分页的高度阈值px默认 500
*/
export function useSmartPagination<T>(
items: ComputedRef<T[]> | Ref<T[]>,
listRef: Ref<HTMLElement | null>,
maxHeight = 500,
) {
const currentPage = ref(1)
const itemsPerPage = ref(0) // 0 = 不分页
const cachedAvgItemHeight = ref(0)
const shouldPaginate = computed(() => {
return itemsPerPage.value > 0 && items.value.length > itemsPerPage.value
})
const paginatedItems = computed(() => {
if (!shouldPaginate.value) return items.value
const start = (currentPage.value - 1) * itemsPerPage.value
return items.value.slice(start, start + itemsPerPage.value)
})
const totalPages = computed(() => {
if (!shouldPaginate.value) return 1
return Math.ceil(items.value.length / itemsPerPage.value)
})
/** 将当前页内的局部索引转换为全局索引 */
function getGlobalIndex(localIdx: number): number {
if (!shouldPaginate.value) return localIdx
return (currentPage.value - 1) * itemsPerPage.value + localIdx
}
/** 检测是否需要分页并计算每页条数 */
function detect() {
const el = listRef.value
if (!el || items.value.length <= 2) {
itemsPerPage.value = 0
return
}
const scrollHeight = el.scrollHeight
const renderedCount = paginatedItems.value.length
if (renderedCount > 0 && scrollHeight > 0) {
cachedAvgItemHeight.value = scrollHeight / renderedCount
}
const estimatedTotalHeight = cachedAvgItemHeight.value * items.value.length
if (estimatedTotalHeight > maxHeight && cachedAvgItemHeight.value > 0) {
itemsPerPage.value = Math.max(Math.floor(maxHeight / cachedAvgItemHeight.value), 3)
const maxPage = Math.ceil(items.value.length / itemsPerPage.value)
if (currentPage.value > maxPage) {
currentPage.value = maxPage
}
} else {
itemsPerPage.value = 0
}
}
/** 重置分页状态(数据源切换时调用) */
function reset() {
currentPage.value = 1
itemsPerPage.value = 0
cachedAvgItemHeight.value = 0
}
// 数据源变化时自动重新检测immediate 确保首次挂载时也检测)
watch(items, () => {
currentPage.value = 1
nextTick(detect)
}, { immediate: true, flush: 'post' })
return {
currentPage,
totalPages,
shouldPaginate,
paginatedItems,
getGlobalIndex,
detect,
reset,
}
}

View File

@@ -296,7 +296,7 @@
v-if="getEndpointEditBodyRules(endpoint.id).length > 0" v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
class="text-xs text-muted-foreground px-2" class="text-xs text-muted-foreground px-2"
> >
<code class="bg-muted px-1 rounded">.</code> 访问嵌套字段;值为 JSON 格式,字符串需加引号如 <code class="bg-muted px-1 rounded">"text"</code> <code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引;值为 JSON 格式
</div> </div>
<!-- 请求体规则列表 - 次要色边框 --> <!-- 请求体规则列表 - 次要色边框 -->
@@ -312,10 +312,10 @@
<Select <Select
:model-value="rule.action" :model-value="rule.action"
:open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]" :open="bodyRuleSelectOpen[`${endpoint.id}-${index}`]"
@update:model-value="(v) => updateEndpointBodyRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')" @update:model-value="(v: string) => updateEndpointBodyRuleAction(endpoint.id, index, v as BodyRuleAction)"
@update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)" @update:open="(v) => handleBodyRuleSelectOpen(endpoint.id, index, v)"
> >
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0"> <SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -328,6 +328,12 @@
<SelectItem value="rename"> <SelectItem value="rename">
重命名 重命名
</SelectItem> </SelectItem>
<SelectItem value="insert">
插入
</SelectItem>
<SelectItem value="regex_replace">
正则替换
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<template v-if="rule.action === 'set'"> <template v-if="rule.action === 'set'">
@@ -378,6 +384,72 @@
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)" @update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'to', v)"
/> />
</template> </template>
<template v-else-if="rule.action === 'insert' || rule.action === 'append'">
<Input
:model-value="rule.path"
placeholder="数组路径 messages"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<Input
:model-value="rule.index"
placeholder="末尾"
size="sm"
class="w-14 h-7 text-xs shrink-0"
title="插入位置留空=追加到末尾"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'index', v)"
/>
<Input
:model-value="rule.value"
placeholder=" (JSON)"
size="sm"
class="flex-[3] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'value', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getBodySetValueValidation(rule) === true ? 'text-green-600' : getBodySetValueValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getBodySetValueValidationTip(rule)"
/>
</template>
<template v-else-if="rule.action === 'regex_replace'">
<Input
:model-value="rule.path"
placeholder="字段路径"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'path', v)"
/>
<Input
:model-value="rule.pattern"
placeholder="正则"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs font-mono"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'pattern', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.replacement"
placeholder="替换为"
size="sm"
class="flex-[2] min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'replacement', v)"
/>
<Input
:model-value="rule.flags"
placeholder="ims"
size="sm"
class="w-12 h-7 text-xs shrink-0 font-mono"
title="正则标志i=忽略大小写 m=多行 s=dotall"
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'flags', v)"
/>
<CheckCircle
class="w-4 h-4 shrink-0"
:class="getRegexPatternValidation(rule) === true ? 'text-green-600' : getRegexPatternValidation(rule) === false ? 'text-destructive' : 'text-muted-foreground/40'"
:title="getRegexPatternValidationTip(rule)"
/>
</template>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -519,6 +591,7 @@ import {
type ProviderWithEndpointsSummary, type ProviderWithEndpointsSummary,
type HeaderRule, type HeaderRule,
type BodyRule, type BodyRule,
type BodyRuleRegexReplace,
} from '@/api/endpoints' } from '@/api/endpoints'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
@@ -532,12 +605,18 @@ interface EditableRule {
} }
// 编辑用的请求体规则类型 // 编辑用的请求体规则类型
type BodyRuleAction = 'set' | 'drop' | 'rename' | 'append' | 'insert' | 'regex_replace'
interface EditableBodyRule { interface EditableBodyRule {
action: 'set' | 'drop' | 'rename' action: BodyRuleAction
path: string // set/drop 用 path: string // set/drop/append/insert/regex_replace
value: string // set 用JSON 格式) value: string // set/append/insertJSON 格式)
from: string // rename 用 from: string // rename 用
to: string // rename 用 to: string // rename 用
index: string // insert 用(字符串输入,保存时解析为 int
pattern: string // regex_replace 用
replacement: string // regex_replace 用
flags: string // regex_replace 用i/m/s
} }
// 端点编辑状态(仅 URL、路径、规则格式转换是直接保存的 // 端点编辑状态(仅 URL、路径、规则格式转换是直接保存的
@@ -779,16 +858,29 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
} }
} }
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '',
})
const bodyRules: EditableBodyRule[] = [] const bodyRules: EditableBodyRule[] = []
if (endpoint.body_rules && endpoint.body_rules.length > 0) { if (endpoint.body_rules && endpoint.body_rules.length > 0) {
for (const rule of endpoint.body_rules) { for (const rule of endpoint.body_rules) {
if (rule.action === 'set') { if (rule.action === 'set') {
const { value } = initBodyRuleSetValueForEditor((rule as any).value) const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ action: 'set', path: rule.path, value, from: '', to: '' }) bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value })
} else if (rule.action === 'drop') { } else if (rule.action === 'drop') {
bodyRules.push({ action: 'drop', path: rule.path, value: '', from: '', to: '' }) bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path })
} else if (rule.action === 'rename') { } else if (rule.action === 'rename') {
bodyRules.push({ action: 'rename', path: '', value: '', from: rule.from, to: rule.to }) bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to })
} else if (rule.action === 'append') {
// 前端将 append 统一展示为 insertindex 留空),保存时再根据 index 是否为空转回 append
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '' })
} else if (rule.action === 'insert') {
const { value } = initBodyRuleSetValueForEditor(rule.value)
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? '') })
} else if (rule.action === 'regex_replace') {
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '' })
} }
} }
} }
@@ -977,7 +1069,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
// 添加请求体规则(同时自动展开折叠) // 添加请求体规则(同时自动展开折叠)
function handleAddEndpointBodyRule(endpointId: string) { function handleAddEndpointBodyRule(endpointId: string) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
rules.push({ action: 'set', path: '', value: '', from: '', to: '' }) rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '' })
// 自动展开折叠 // 自动展开折叠
endpointRulesExpanded.value[endpointId] = true endpointRulesExpanded.value[endpointId] = true
} }
@@ -989,7 +1081,7 @@ function removeEndpointBodyRule(endpointId: string, index: number) {
} }
// 更新请求体规则类型 // 更新请求体规则类型
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') { function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) { if (rules[index]) {
rules[index].action = action rules[index].action = action
@@ -997,11 +1089,15 @@ function updateEndpointBodyRuleAction(endpointId: string, index: number, action:
rules[index].value = '' rules[index].value = ''
rules[index].from = '' rules[index].from = ''
rules[index].to = '' rules[index].to = ''
rules[index].index = ''
rules[index].pattern = ''
rules[index].replacement = ''
rules[index].flags = ''
} }
} }
// 更新请求体规则字段 // 更新请求体规则字段
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to', value: string) { function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags', value: string) {
const rules = getEndpointEditBodyRules(endpointId) const rules = getEndpointEditBodyRules(endpointId)
if (rules[index]) { if (rules[index]) {
rules[index][field] = value rules[index][field] = value
@@ -1013,11 +1109,14 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
const raw = path.trim() const raw = path.trim()
if (!raw) return null if (!raw) return null
const parts = parseBodyRulePathParts(raw) // 基础格式校验;对含 [N] 的路径,取方括号前的部分做 dot 校验
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
if (!parts) { if (!parts) {
return '路径格式无效(不允许 .a / a. / a..b' return '路径格式无效'
} }
// 提取顶层 key去除数组索引部分
const topKey = (parts[0] || '').trim().toLowerCase() const topKey = (parts[0] || '').trim().toLowerCase()
if (RESERVED_BODY_FIELDS.has(topKey)) { if (RESERVED_BODY_FIELDS.has(topKey)) {
return `"${parts[0]}" 是系统保留的顶层字段` return `"${parts[0]}" 是系统保留的顶层字段`
@@ -1101,7 +1200,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
} }
function validateBodySetValue(rule: EditableBodyRule): string | null { function validateBodySetValue(rule: EditableBodyRule): string | null {
if (rule.action !== 'set') return null if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
const raw = rule.value.trim() const raw = rule.value.trim()
if (!raw) return '值不能为空' if (!raw) return '值不能为空'
@@ -1116,7 +1215,7 @@ function validateBodySetValue(rule: EditableBodyRule): string | null {
// 获取值验证状态true=有效, false=无效, null=空 // 获取值验证状态true=有效, false=无效, null=空
function getBodySetValueValidation(rule: EditableBodyRule): boolean | null { function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
if (rule.action !== 'set') return null if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
const raw = rule.value.trim() const raw = rule.value.trim()
if (!raw) return null if (!raw) return null
try { try {
@@ -1127,6 +1226,41 @@ function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
} }
} }
// 正则表达式验证状态true=有效, false=无效, null=空
function getRegexPatternValidation(rule: EditableBodyRule): boolean | null {
if (rule.action !== 'regex_replace') return null
const pattern = rule.pattern.trim()
if (!pattern) return null
try {
new RegExp(pattern)
// 校验 flags
const flags = rule.flags.trim()
if (flags) {
const validFlags = new Set(['i', 'm', 's'])
for (const f of flags) {
if (!validFlags.has(f)) return false
}
}
return true
} catch {
return false
}
}
// 获取正则验证提示
function getRegexPatternValidationTip(rule: EditableBodyRule): string {
const validation = getRegexPatternValidation(rule)
if (validation === null) return '输入正则表达式'
if (validation === true) return '有效的正则表达式'
try {
new RegExp(rule.pattern.trim())
// 正则有效但 flags 无效
return '无效的 flags仅允许 i/m/s'
} catch (err: any) {
return err instanceof Error ? err.message : String(err)
}
}
// 获取验证提示 // 获取验证提示
function getBodySetValueValidationTip(rule: EditableBodyRule): string { function getBodySetValueValidationTip(rule: EditableBodyRule): string {
const validation = getBodySetValueValidation(rule) const validation = getBodySetValueValidation(rule)
@@ -1151,6 +1285,8 @@ function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
return state.bodyRules.filter(r => { return state.bodyRules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.path.trim() if (r.action === 'set' || r.action === 'drop') return r.path.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim() if (r.action === 'rename') return r.from.trim() && r.to.trim()
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
return false return false
}).length }).length
} }
@@ -1197,6 +1333,13 @@ function _formatBodyRuleLabel(rule: EditableBodyRule): string {
} else if (rule.action === 'rename') { } else if (rule.action === 'rename') {
if (!rule.from || !rule.to) return '(未设置)' if (!rule.from || !rule.to) return '(未设置)'
return `${rule.from}${rule.to}` return `${rule.from}${rule.to}`
} else if (rule.action === 'insert' || rule.action === 'append') {
if (!rule.path) return '(未设置)'
const idx = rule.index?.trim() || '末尾'
return `${rule.path}[${idx}]+=${rule.value || '...'}`
} else if (rule.action === 'regex_replace') {
if (!rule.path || !rule.pattern) return '(未设置)'
return `${rule.path}: s/${rule.pattern}/${rule.replacement || ''}/`
} }
return '(未知)' return '(未知)'
} }
@@ -1210,6 +1353,8 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
const editedRules = state.bodyRules.filter(r => { const editedRules = state.bodyRules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.path.trim() if (r.action === 'set' || r.action === 'drop') return r.path.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim() if (r.action === 'rename') return r.from.trim() && r.to.trim()
if (r.action === 'insert' || r.action === 'append') return r.path.trim()
if (r.action === 'regex_replace') return r.path.trim() && r.pattern.trim()
return false return false
}) })
if (editedRules.length !== originalRules.length) return true if (editedRules.length !== originalRules.length) return true
@@ -1219,13 +1364,29 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
if (!original) return true if (!original) return true
if (edited.action !== original.action) return true if (edited.action !== original.action) return true
if (edited.action === 'set' && original.action === 'set') { if (edited.action === 'set' && original.action === 'set') {
const baseline = initBodyRuleSetValueForEditor((original as any).value) const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true if (edited.value !== baseline.value) return true
} else if (edited.action === 'drop' && original.action === 'drop') { } else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.path !== original.path) return true if (edited.path !== original.path) return true
} else if (edited.action === 'rename' && original.action === 'rename') { } else if (edited.action === 'rename' && original.action === 'rename') {
if (edited.from !== original.from || edited.to !== original.to) return true if (edited.from !== original.from || edited.to !== original.to) return true
} else if (edited.action === 'insert' && original.action === 'append') {
// append 加载时被标准化为 insertindex 为空),比对时需跨 action 匹配
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.index.trim() !== '') return true // 加了 index → 已修改
if (edited.path !== original.path) return true
if (edited.value !== baseline.value) return true
} else if (edited.action === 'insert' && original.action === 'insert') {
const baseline = initBodyRuleSetValueForEditor(original.value)
if (edited.path !== original.path) return true
if (edited.index !== String(original.index ?? '')) return true
if (edited.value !== baseline.value) return true
} else if (edited.action === 'regex_replace' && original.action === 'regex_replace') {
if (edited.path !== original.path) return true
if (edited.pattern !== (original.pattern ?? '')) return true
if (edited.replacement !== (original.replacement ?? '')) return true
if (edited.flags !== (original.flags ?? '')) return true
} }
} }
return false return false
@@ -1238,17 +1399,33 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
for (const rule of rules) { for (const rule of rules) {
if (rule.action === 'set' && rule.path.trim()) { if (rule.action === 'set' && rule.path.trim()) {
let value: any = rule.value let value: any = rule.value
try { try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
value = JSON.parse(rule.value.trim())
} catch {
// 保存前会做校验;这里兜底避免 UI 崩溃
value = rule.value
}
result.push({ action: 'set', path: rule.path.trim(), value }) result.push({ action: 'set', path: rule.path.trim(), value })
} else if (rule.action === 'drop' && rule.path.trim()) { } else if (rule.action === 'drop' && rule.path.trim()) {
result.push({ action: 'drop', path: rule.path.trim() }) result.push({ action: 'drop', path: rule.path.trim() })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) { } else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() }) result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
} else if ((rule.action === 'insert' || rule.action === 'append') && rule.path.trim()) {
let value: any = rule.value
try { value = JSON.parse(rule.value.trim()) } catch { value = rule.value }
const indexStr = rule.index.trim()
if (indexStr === '') {
// 索引留空 → append 到末尾
result.push({ action: 'append', path: rule.path.trim(), value })
} else {
const idx = parseInt(indexStr, 10)
if (isNaN(idx)) continue
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value })
}
} else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
const entry: BodyRuleRegexReplace = {
action: 'regex_replace',
path: rule.path.trim(),
pattern: rule.pattern,
replacement: rule.replacement || '',
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
}
result.push(entry)
} }
} }
@@ -1273,6 +1450,29 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
if (fromErr) return `${prefix}${fromErr}` if (fromErr) return `${prefix}${fromErr}`
const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i) const toErr = validateBodyRenameToForEndpoint(endpointId, rule.to, i)
if (toErr) return `${prefix}${toErr}` if (toErr) return `${prefix}${toErr}`
} else if (rule.action === 'insert' || rule.action === 'append') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
const indexStr = rule.index.trim()
if (indexStr !== '' && isNaN(parseInt(indexStr, 10))) return `${prefix}位置必须为整数或留空`
const valueErr = validateBodySetValue(rule)
if (valueErr) return `${prefix}${valueErr}`
} else if (rule.action === 'regex_replace') {
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
if (pathErr) return `${prefix}${pathErr}`
if (!rule.pattern.trim()) return `${prefix}正则表达式不能为空`
try {
new RegExp(rule.pattern.trim())
} catch (err: any) {
return `${prefix}正则表达式无效${err instanceof Error ? err.message : String(err)}`
}
const flags = rule.flags.trim()
if (flags) {
const validFlags = new Set(['i', 'm', 's'])
for (const f of flags) {
if (!validFlags.has(f)) return `${prefix}flags 仅允许 i/m/s非法字符: ${f}`
}
}
} }
} }
return null return null

View File

@@ -6,6 +6,58 @@
size="md" size="md"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<!-- 右上角代理按钮 -->
<template #header-actions>
<Popover
:open="proxyPopoverOpen"
@update:open="(v: boolean) => { proxyPopoverOpen = v; if (v) proxyNodesStore.ensureLoaded() }"
>
<PopoverTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors shrink-0"
:class="selectedProxyNodeId
? 'text-blue-500 bg-blue-500/10 hover:bg-blue-500/20'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
:title="selectedProxyNodeId ? `代理: ${getSelectedNodeLabel()}` : '设置代理节点'"
>
<Globe class="w-4 h-4" />
</button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3 z-[80]"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium">代理节点</span>
<span
v-if="!proxyNodesStore.loading && proxyNodesStore.onlineNodes.length === 0"
class="text-[10px] text-muted-foreground"
>· 前往模块管理 · 代理节点添加</span>
</div>
<button
v-if="selectedProxyNodeId"
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
@click="selectedProxyNodeId = ''; proxyPopoverOpen = false"
>
清除
</button>
</div>
<ProxyNodeSelect
:model-value="selectedProxyNodeId"
trigger-class="h-8"
@update:model-value="(v: string) => { selectedProxyNodeId = v; proxyPopoverOpen = false }"
/>
<p class="text-[10px] text-muted-foreground">
{{ selectedProxyNodeId ? '授权、刷新、额度查询均走此代理' : '未设置,依次回退到提供商代理 → 系统代理' }}
</p>
</div>
</PopoverContent>
</Popover>
</template>
<div class="space-y-4"> <div class="space-y-4">
<!-- Tab 切换 --> <!-- Tab 切换 -->
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30"> <div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
@@ -227,8 +279,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Dialog, Button, Textarea } from '@/components/ui' import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next' import { UserPlus, Copy, ExternalLink, Upload, Globe } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
@@ -238,6 +290,8 @@ import {
importProviderRefreshToken, importProviderRefreshToken,
batchImportOAuth, batchImportOAuth,
} from '@/api/endpoints' } from '@/api/endpoints'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -252,6 +306,18 @@ const emit = defineEmits<{
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const proxyNodesStore = useProxyNodesStore()
// 代理节点选择
const proxyPopoverOpen = ref(false)
const selectedProxyNodeId = ref('')
/** 获取已选代理节点的显示名称 */
function getSelectedNodeLabel(): string {
if (!selectedProxyNodeId.value) return ''
const node = proxyNodesStore.nodes.find(n => n.id === selectedProxyNodeId.value)
return node ? node.name : `${selectedProxyNodeId.value.slice(0, 8) }...`
}
// 模式 // 模式
type DialogMode = 'oauth' | 'import' type DialogMode = 'oauth' | 'import'
@@ -318,6 +384,8 @@ function resetForm() {
importing.value = false importing.value = false
isDragging.value = false isDragging.value = false
showManualInput.value = false showManualInput.value = false
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = isKiroProvider.value ? 'import' : 'oauth' mode.value = isKiroProvider.value ? 'import' : 'oauth'
if (fileInputRef.value) { if (fileInputRef.value) {
fileInputRef.value.value = '' fileInputRef.value.value = ''
@@ -393,6 +461,7 @@ async function handleCompleteOAuth() {
try { try {
await completeProviderLevelOAuth(props.providerId, { await completeProviderLevelOAuth(props.providerId, {
callback_url: oauth.value.callback_url.trim(), callback_url: oauth.value.callback_url.trim(),
proxy_node_id: selectedProxyNodeId.value || undefined,
}) })
success('授权成功,账号已添加') success('授权成功,账号已添加')
emit('saved') emit('saved')
@@ -498,10 +567,11 @@ async function handleImport() {
importing.value = true importing.value = true
try { try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// 检测是否为批量导入 // 检测是否为批量导入
if (isBatchImport(inputText)) { if (isBatchImport(inputText)) {
// 批量导入 // 批量导入
const result = await batchImportOAuth(props.providerId, inputText) const result = await batchImportOAuth(props.providerId, inputText, proxyNodeId)
if (result.success > 0) { if (result.success > 0) {
if (result.failed > 0) { if (result.failed > 0) {
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed}`) success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed}`)
@@ -522,7 +592,10 @@ async function handleImport() {
showError('无法解析输入内容,请检查格式', '格式错误') showError('无法解析输入内容,请检查格式', '格式错误')
return return
} }
await importProviderRefreshToken(props.providerId, parsed) await importProviderRefreshToken(props.providerId, {
...parsed,
proxy_node_id: proxyNodeId,
})
success('导入成功,账号已添加') success('导入成功,账号已添加')
emit('saved') emit('saved')
handleClose() handleClose()
@@ -537,6 +610,8 @@ async function handleImport() {
watch(() => props.open, (newOpen) => { watch(() => props.open, (newOpen) => {
if (newOpen) { if (newOpen) {
// 预加载代理节点列表
proxyNodesStore.ensureLoaded()
if (isKiroProvider.value) { if (isKiroProvider.value) {
mode.value = 'import' mode.value = 'import'
} else { } else {

View File

@@ -206,23 +206,24 @@
<!-- 密钥列表 --> <!-- 密钥列表 -->
<div <div
v-if="allKeys.length > 0" v-if="allKeys.length > 0"
ref="keysListRef"
class="divide-y divide-border/40" class="divide-y divide-border/40"
> >
<div <div
v-for="({ key, endpoint }, index) in allKeys" v-for="({ key, endpoint }, localIdx) in paginatedKeys"
:key="key.id" :key="key.id"
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item" class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
:class="{ :class="{
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index, 'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === getGlobalKeyIndex(localIdx),
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging, 'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === getGlobalKeyIndex(localIdx) && keyDragState.isDragging,
'opacity-40 bg-muted/20': !key.is_active 'opacity-40 bg-muted/20': !key.is_active
}" }"
draggable="true" draggable="true"
@dragstart="handleKeyDragStart($event, index)" @dragstart="handleKeyDragStart($event, getGlobalKeyIndex(localIdx))"
@dragend="handleKeyDragEnd" @dragend="handleKeyDragEnd"
@dragover="handleKeyDragOver($event, index)" @dragover="handleKeyDragOver($event, getGlobalKeyIndex(localIdx))"
@dragleave="handleKeyDragLeave" @dragleave="handleKeyDragLeave"
@drop="handleKeyDrop($event, index)" @drop="handleKeyDrop($event, getGlobalKeyIndex(localIdx))"
> >
<!-- 第一行名称 + 状态 + 操作按钮 --> <!-- 第一行名称 + 状态 + 操作按钮 -->
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
@@ -760,6 +761,34 @@
</template> </template>
</div> </div>
</div> </div>
<!-- 分页控制 -->
<div
v-if="shouldPaginateKeys"
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground"
>
<span>共 {{ allKeys.length }} 个{{ provider.provider_type === 'custom' ? '密钥' : '账号' }}</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentKeyPage <= 1"
@click="currentKeyPage--"
>
</Button>
<span class="tabular-nums">{{ currentKeyPage }} / {{ totalKeyPages }}</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentKeyPage >= totalKeyPages"
@click="currentKeyPage++"
>
</Button>
</div>
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
@@ -900,6 +929,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue' import { ref, watch, computed, nextTick } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { import {
Plus, Plus,
Key, Key,
@@ -1104,6 +1134,17 @@ const allKeys = computed(() => {
return result return result
}) })
// ===== 账号列表智能分页 =====
const keysListRef = ref<HTMLElement | null>(null)
const {
currentPage: currentKeyPage,
totalPages: totalKeyPages,
shouldPaginate: shouldPaginateKeys,
paginatedItems: paginatedKeys,
getGlobalIndex: getGlobalKeyIndex,
reset: resetKeysPagination,
} = useSmartPagination(allKeys, keysListRef)
// 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求 // 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求
watch( watch(
[() => props.providerId, () => props.open], [() => props.providerId, () => props.open],
@@ -1126,6 +1167,9 @@ watch(
endpoints.value = [] endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys providerKeys.value = [] // 清空 Provider 级别的 keys
// 重置分页状态
resetKeysPagination()
// 重置所有对话框状态 // 重置所有对话框状态
endpointDialogOpen.value = false endpointDialogOpen.value = false
keyFormDialogOpen.value = false keyFormDialogOpen.value = false

View File

@@ -24,12 +24,6 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
class="text-xs text-muted-foreground"
>
暂无在线代理节点,请在「代理节点」页面添加
</p>
</div> </div>
</template> </template>

View File

@@ -29,23 +29,24 @@
<!-- 映射列表 --> <!-- 映射列表 -->
<div <div
v-else-if="combinedMappings.length > 0" v-else-if="combinedMappings.length > 0"
ref="mappingsListRef"
class="divide-y divide-border/40" class="divide-y divide-border/40"
> >
<div <div
v-for="(item, index) in combinedMappings" v-for="item in paginatedMappings"
:key="item.key" :key="item.key"
class="transition-colors" class="transition-colors"
> >
<!-- 行头部可点击展开 --> <!-- 行头部可点击展开 -->
<div <div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer" class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleExpand(index)" @click="toggleExpand(item.key)"
> >
<div class="flex items-center gap-2 flex-1 min-w-0"> <div class="flex items-center gap-2 flex-1 min-w-0">
<!-- 展开/收起图标 --> <!-- 展开/收起图标 -->
<ChevronRight <ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5" class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
:class="{ 'rotate-90': expandedItems.has(index) }" :class="{ 'rotate-90': expandedItems.has(item.key) }"
/> />
<!-- 精确映射 --> <!-- 精确映射 -->
<template v-if="item.type === 'exact'"> <template v-if="item.type === 'exact'">
@@ -134,7 +135,7 @@
<!-- 展开的映射详情 --> <!-- 展开的映射详情 -->
<div <div
v-show="expandedItems.has(index)" v-show="expandedItems.has(item.key)"
class="bg-muted/30 border-t border-border/30" class="bg-muted/30 border-t border-border/30"
> >
<!-- 精确映射详情 --> <!-- 精确映射详情 -->
@@ -272,6 +273,34 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 分页控制 -->
<div
v-if="shouldPaginateMappings"
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ combinedMappings.length }} 个映射</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentMappingPage <= 1"
@click="currentMappingPage--"
>
</Button>
<span class="tabular-nums">{{ currentMappingPage }} / {{ totalMappingPages }}</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentMappingPage >= totalMappingPages"
@click="currentMappingPage++"
>
</Button>
</div>
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
@@ -315,6 +344,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next' import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
import { import {
Card, Button, Badge, Card, Button, Badge,
@@ -393,7 +423,7 @@ const providerKeysState = ref<EndpointAPIKey[]>([])
const formatMenuOpen = ref<Record<string, boolean>>({}) const formatMenuOpen = ref<Record<string, boolean>>({})
// 展开状态 // 展开状态
const expandedItems = ref<Set<number>>(new Set()) const expandedItems = ref<Set<string>>(new Set())
// 是否有 key 配置了自动获取上游模型 // 是否有 key 配置了自动获取上游模型
const hasAutoFetchKey = computed(() => { const hasAutoFetchKey = computed(() => {
@@ -523,6 +553,15 @@ const combinedMappings = computed<CombinedMapping[]>(() => {
}) })
}) })
// ===== 模型映射智能分页 =====
const mappingsListRef = ref<HTMLElement | null>(null)
const {
currentPage: currentMappingPage,
totalPages: totalMappingPages,
shouldPaginate: shouldPaginateMappings,
paginatedItems: paginatedMappings,
} = useSmartPagination(combinedMappings, mappingsListRef)
// 加载数据 // 加载数据
async function loadData() { async function loadData() {
try { try {
@@ -554,11 +593,11 @@ const deleteConfirmDescription = computed(() => {
}) })
// 切换展开状态 // 切换展开状态
function toggleExpand(index: number) { function toggleExpand(key: string) {
if (expandedItems.value.has(index)) { if (expandedItems.value.has(key)) {
expandedItems.value.delete(index) expandedItems.value.delete(key)
} else { } else {
expandedItems.value.add(index) expandedItems.value.add(key)
} }
} }

View File

@@ -31,7 +31,10 @@
v-else-if="models.length > 0" v-else-if="models.length > 0"
class="overflow-hidden" class="overflow-hidden"
> >
<table class="w-full text-sm table-fixed"> <table
ref="modelsListRef"
class="w-full text-sm table-fixed"
>
<colgroup> <colgroup>
<col class="w-[45%]"> <col class="w-[45%]">
<col class="w-[30%]"> <col class="w-[30%]">
@@ -39,7 +42,7 @@
</colgroup> </colgroup>
<tbody> <tbody>
<tr <tr
v-for="model in sortedModels" v-for="model in paginatedModels"
:key="model.id" :key="model.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors" class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
> >
@@ -196,6 +199,34 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- 分页控制 -->
<div
v-if="shouldPaginateModels"
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ sortedModels.length }} 个模型</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentModelPage <= 1"
@click="currentModelPage--"
>
</Button>
<span class="tabular-nums">{{ currentModelPage }} / {{ totalModelPages }}</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentModelPage >= totalModelPages"
@click="currentModelPage++"
>
</Button>
</div>
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
@@ -216,6 +247,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next' import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
@@ -283,6 +315,15 @@ const sortedModels = computed(() => {
}) })
}) })
// ===== 模型列表智能分页 =====
const modelsListRef = ref<HTMLElement | null>(null)
const {
currentPage: currentModelPage,
totalPages: totalModelPages,
shouldPaginate: shouldPaginateModels,
paginatedItems: paginatedModels,
} = useSmartPagination(sortedModels, modelsListRef)
// 复制模型 ID 到剪贴板 // 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) { async function copyModelId(modelId: string) {
await copyToClipboard(modelId) await copyToClipboard(modelId)

View File

@@ -41,6 +41,90 @@ const headerRuleTypes = [
{ type: 'remove', name: '删除', description: '删除指定的请求头' } { type: 'remove', name: '删除', description: '删除指定的请求头' }
] ]
// 请求体规则类型
const bodyRuleTypes = [
{ action: 'set', name: '覆写', description: '设置或覆盖指定路径的字段值' },
{ action: 'drop', name: '删除', description: '删除指定路径的字段' },
{ action: 'rename', name: '重命名', description: '将字段从一个路径移动到另一个路径' },
{ action: 'insert', name: '插入', description: '在数组的指定位置插入元素,位置留空则追加到末尾' },
{ action: 'regex_replace', name: '正则替换', description: '对字符串字段执行正则表达式替换' },
]
// 请求体规则示例
const bodyRuleExamples = [
{
title: '注入系统提示词',
description: '在 messages 数组开头插入一条 system 消息index: 0',
rule: `{
"action": "insert",
"path": "messages",
"index": 0,
"value": {
"role": "system",
"content": "你是一个专业助手"
}
}`,
},
{
title: '追加消息到末尾',
description: '不指定 index自动追加到数组末尾',
rule: `{
"action": "insert",
"path": "messages",
"value": {
"role": "user",
"content": "请用中文回答"
}
}`,
},
{
title: '设置自定义元数据',
description: '覆写嵌套字段,不存在时自动创建中间层级',
rule: `{
"action": "set",
"path": "metadata.source",
"value": "internal-app"
}`,
},
{
title: '删除不需要的字段',
description: '移除请求体中的敏感或多余字段',
rule: `{
"action": "drop",
"path": "user_info.ip_address"
}`,
},
{
title: '内容脱敏',
description: '用正则替换 messages 中的手机号',
rule: `{
"action": "regex_replace",
"path": "messages[-1].content",
"pattern": "1[3-9]\\\\d{9}",
"replacement": "[手机号已隐藏]",
"flags": ""
}`,
},
{
title: '重命名字段',
description: '将字段从旧路径移动到新路径',
rule: `{
"action": "rename",
"from": "extra.custom_id",
"to": "metadata.trace_id"
}`,
},
]
// 路径语法示例
const pathSyntaxExamples = [
{ path: 'metadata.user', desc: '嵌套 dict 字段' },
{ path: 'messages[0].content', desc: '数组第一个元素的 content 字段' },
{ path: 'messages[-1]', desc: '数组最后一个元素' },
{ path: 'matrix[0][1]', desc: '多维数组访问' },
{ path: 'config\\.v1.enabled', desc: '\\. 转义为字面量点号 → key "config.v1"' },
]
// 系统设置分类 // 系统设置分类
const systemSettings = [ const systemSettings = [
{ {
@@ -273,6 +357,271 @@ const systemSettings = [
</div> </div>
</section> </section>
<!-- 请求体规则 -->
<section class="space-y-4">
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">
请求体规则
</h2>
<!-- 概述 -->
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-orange-500/10">
<FileCode class="h-5 w-5 text-orange-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
什么是请求体规则
</h3>
</div>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
请求体规则允许你在转发请求时修改请求体JSON Body的内容可以覆写字段删除字段向数组追加/插入元素甚至用正则替换字符串值
规则按顺序依次执行受保护的顶层字段<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">model</code><code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">stream</code>不可修改
</p>
<!-- 操作类型表格 -->
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
操作
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
</tr>
</thead>
<tbody>
<tr
v-for="rule in bodyRuleTypes"
:key="rule.action"
class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] last:border-0"
>
<td class="px-4 py-3 font-medium text-[#262624] dark:text-[#f1ead8]">
<span :class="panelClasses.badge">{{ rule.name }}</span>
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
{{ rule.description }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 路径语法 -->
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
路径语法
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
使用点号<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">.</code>分隔层级方括号<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">[N]</code>访问数组元素
</p>
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
路径
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
</tr>
</thead>
<tbody>
<tr
v-for="ex in pathSyntaxExamples"
:key="ex.path"
class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] last:border-0"
>
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
{{ ex.path }}
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
{{ ex.desc }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 实战示例 -->
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
实战示例
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div
v-for="example in bodyRuleExamples"
:key="example.title"
class="rounded-lg border border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] overflow-hidden"
>
<div class="px-4 py-2.5 border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<p class="font-medium text-sm text-[#262624] dark:text-[#f1ead8]">
{{ example.title }}
</p>
<p class="text-xs text-[#666663] dark:text-[#a3a094] mt-0.5">
{{ example.description }}
</p>
</div>
<pre class="p-4 text-xs font-mono text-[#262624] dark:text-[#f1ead8] overflow-x-auto bg-[#fafaf7]/30 dark:bg-[#1a1816]/30"><code>{{ example.rule }}</code></pre>
</div>
</div>
</div>
<!-- 正则替换说明 -->
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
正则替换详解
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">regex_replace</code> 对指定路径的字符串值执行正则表达式替换
</p>
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
参数
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
必填
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
path
</td>
<td class="px-4 py-3">
<Check class="h-4 w-4 text-green-500" />
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
目标字符串字段的路径
</td>
</tr>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
pattern
</td>
<td class="px-4 py-3">
<Check class="h-4 w-4 text-green-500" />
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
正则表达式Python re 语法保存时会校验合法性
</td>
</tr>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
replacement
</td>
<td class="px-4 py-3">
<Check class="h-4 w-4 text-green-500" />
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
替换字符串留空则删除匹配内容
</td>
</tr>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.08)]">
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
flags
</td>
<td class="px-4 py-3 text-xs text-[#999]">
可选
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">i</code> 忽略大小写 /
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">m</code> 多行模式 /
<code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1 py-0.5 rounded">s</code> dotall. 匹配换行
</td>
</tr>
<tr class="last:border-0">
<td class="px-4 py-3 font-mono text-xs text-[#262624] dark:text-[#f1ead8]">
count
</td>
<td class="px-4 py-3 text-xs text-[#999]">
可选
</td>
<td class="px-4 py-3 text-[#666663] dark:text-[#a3a094]">
替换次数默认 0 = 全部替换
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 使用场景 -->
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
典型使用场景
</p>
<ul class="mt-1 space-y-1">
<li>
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">注入 System Prompt</span> 在所有请求前插入统一的系统提示词
</li>
<li>
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">请求增强</span> 自动追加上下文metadata 等字段
</li>
<li>
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">内容过滤</span> 用正则替换脱敏敏感信息手机号邮箱等
</li>
<li>
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">字段清理</span> 删除不需要的自定义字段避免上游报错
</li>
<li>
<span class="font-medium text-[#262624] dark:text-[#f1ead8]">字段适配</span> 将客户端的字段名重命名为上游期望的格式
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 代理设置 --> <!-- 代理设置 -->
<section class="space-y-4"> <section class="space-y-4">
<h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]"> <h2 class="text-xl font-semibold text-[#262624] dark:text-[#f1ead8]">

View File

@@ -140,6 +140,10 @@ class CompleteOAuthResponse(BaseModel):
class ProviderCompleteOAuthRequest(BaseModel): class ProviderCompleteOAuthRequest(BaseModel):
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL") callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
name: str | None = Field(None, max_length=100, description="账号名称(可选)") name: str | None = Field(None, max_length=100, description="账号名称(可选)")
proxy_node_id: str | None = Field(
None,
description="代理节点 ID可选。设置后 token 交换及后续所有操作(刷新、额度查询)均走该代理,避免 IP 污染",
)
class ProviderCompleteOAuthResponse(BaseModel): class ProviderCompleteOAuthResponse(BaseModel):
@@ -162,6 +166,32 @@ def _require_fixed_provider(provider: Provider) -> str:
return provider_type return provider_type
def _resolve_proxy_for_oauth(
provider_proxy: dict[str, Any] | None,
proxy_node_id: str | None,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""解析 OAuth 操作使用的代理配置。
当前端指定了 proxy_node_id 时,优先使用该代理进行 token 交换等操作,
并返回需要保存到 Key 上的代理配置。
Args:
provider_proxy: Provider 级别的代理配置
proxy_node_id: 前端指定的代理节点 ID可选
Returns:
(effective_proxy, key_proxy):
- effective_proxy: 本次操作实际使用的代理配置
- key_proxy: 需要保存到 Key 上的代理配置None 表示不设置 Key 级代理)
"""
if proxy_node_id and proxy_node_id.strip():
key_proxy: dict[str, Any] = {"node_id": proxy_node_id.strip(), "enabled": True}
# 本次操作使用 Key 级代理
return key_proxy, key_proxy
# 无 Key 级代理,使用 Provider 级代理
return provider_proxy, None
def _pkce_s256(verifier: str) -> str: def _pkce_s256(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("utf-8")).digest() digest = hashlib.sha256(verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
@@ -207,11 +237,14 @@ def _create_oauth_key(
auth_config: dict[str, Any], auth_config: dict[str, Any],
api_formats: list[str], api_formats: list[str],
flush_only: bool = False, flush_only: bool = False,
proxy: dict[str, Any] | None = None,
) -> "ProviderAPIKey": ) -> "ProviderAPIKey":
"""创建 OAuth Key 记录并持久化。 """创建 OAuth Key 记录并持久化。
Args: Args:
flush_only: True 时仅 flush批量导入场景False 时 commit + refresh。 flush_only: True 时仅 flush批量导入场景False 时 commit + refresh。
proxy: Key 级别代理配置(如 {"node_id": "xxx", "enabled": True}
创建时设置后,后续 token 刷新、额度刷新等操作立即走代理,避免 IP 污染。
""" """
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
@@ -224,6 +257,8 @@ def _create_oauth_key(
api_formats=api_formats, api_formats=api_formats,
is_active=True, is_active=True,
) )
if proxy:
new_key.proxy = proxy
db.add(new_key) db.add(new_key)
if flush_only: if flush_only:
db.flush() db.flush()
@@ -929,7 +964,10 @@ async def complete_provider_oauth(
data = form data = form
json_body = None json_body = None
proxy_config = getattr(provider, "proxy", None) # 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
proxy_config, key_proxy = _resolve_proxy_for_oauth(
getattr(provider, "proxy", None), payload.proxy_node_id
)
resp = await post_oauth_token( resp = await post_oauth_token(
provider_type=provider_type, provider_type=provider_type,
@@ -991,6 +1029,7 @@ async def complete_provider_oauth(
access_token=access_token, access_token=access_token,
auth_config=auth_config, auth_config=auth_config,
api_formats=_get_provider_api_formats(provider), api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
) )
return ProviderCompleteOAuthResponse( return ProviderCompleteOAuthResponse(
@@ -1096,6 +1135,10 @@ def _parse_kiro_import_input(raw_input: str) -> list[dict[str, Any]]:
class ImportRefreshTokenRequest(BaseModel): class ImportRefreshTokenRequest(BaseModel):
refresh_token: str = Field(..., min_length=1, description="Refresh Token") refresh_token: str = Field(..., min_length=1, description="Refresh Token")
name: str | None = Field(None, max_length=100, description="账号名称(可选)") name: str | None = Field(None, max_length=100, description="账号名称(可选)")
proxy_node_id: str | None = Field(
None,
description="代理节点 ID可选。设置后导入验证及后续所有操作均走该代理",
)
class BatchImportRequest(BaseModel): class BatchImportRequest(BaseModel):
@@ -1107,6 +1150,10 @@ class BatchImportRequest(BaseModel):
max_length=500_000, max_length=500_000,
description="凭据数据支持多种格式JSON 对象、JSON 数组、纯 Token一行一个", description="凭据数据支持多种格式JSON 对象、JSON 数组、纯 Token一行一个",
) )
proxy_node_id: str | None = Field(
None,
description="代理节点 ID可选。设置后批量导入验证及后续所有操作均走该代理",
)
class BatchImportResultItem(BaseModel): class BatchImportResultItem(BaseModel):
@@ -1148,6 +1195,11 @@ async def import_refresh_token(
raise NotFoundException("Provider 不存在", "provider") raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider) provider_type = _require_fixed_provider(provider)
# 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
proxy_config, key_proxy = _resolve_proxy_for_oauth(
getattr(provider, "proxy", None), payload.proxy_node_id
)
if provider_type == ProviderType.KIRO.value: if provider_type == ProviderType.KIRO.value:
raw_import = payload.refresh_token.strip() raw_import = payload.refresh_token.strip()
if not raw_import: if not raw_import:
@@ -1173,7 +1225,6 @@ async def import_refresh_token(
cfg = KiroAuthConfig.from_dict(raw_cfg) cfg = KiroAuthConfig.from_dict(raw_cfg)
cfg.provider_type = ProviderType.KIRO.value cfg.provider_type = ProviderType.KIRO.value
proxy_config = getattr(provider, "proxy", None)
try: try:
access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config) access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
except Exception as e: except Exception as e:
@@ -1192,6 +1243,7 @@ async def import_refresh_token(
access_token=access_token, access_token=access_token,
auth_config=new_cfg.to_dict(), auth_config=new_cfg.to_dict(),
api_formats=_get_provider_api_formats(provider), api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
) )
return ProviderCompleteOAuthResponse( return ProviderCompleteOAuthResponse(
@@ -1243,7 +1295,7 @@ async def import_refresh_token(
data = form data = form
json_body = None json_body = None
proxy_config = getattr(provider, "proxy", None) # proxy_config 和 key_proxy 已在上方 Kiro 分支之前统一解析
resp = await post_oauth_token( resp = await post_oauth_token(
provider_type=provider_type, provider_type=provider_type,
@@ -1312,6 +1364,7 @@ async def import_refresh_token(
access_token=access_token, access_token=access_token,
auth_config=auth_config, auth_config=auth_config,
api_formats=_get_provider_api_formats(provider), api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
) )
return ProviderCompleteOAuthResponse( return ProviderCompleteOAuthResponse(
@@ -1355,6 +1408,11 @@ async def batch_import_oauth(
provider_type = _require_fixed_provider(provider) provider_type = _require_fixed_provider(provider)
# 解析代理:前端指定 proxy_node_id 时优先使用,否则回退到 Provider 级代理
proxy_config, key_proxy = _resolve_proxy_for_oauth(
getattr(provider, "proxy", None), payload.proxy_node_id
)
# Kiro 使用专用逻辑 # Kiro 使用专用逻辑
if provider_type == ProviderType.KIRO.value: if provider_type == ProviderType.KIRO.value:
return await _batch_import_kiro_internal( return await _batch_import_kiro_internal(
@@ -1362,6 +1420,8 @@ async def batch_import_oauth(
provider=provider, provider=provider,
raw_credentials=payload.credentials, raw_credentials=payload.credentials,
db=db, db=db,
proxy_config=proxy_config,
key_proxy=key_proxy,
) )
# 标准 OAuth ProviderCodex、Antigravity、GeminiCli、ClaudeCode # 标准 OAuth ProviderCodex、Antigravity、GeminiCli、ClaudeCode
@@ -1378,8 +1438,6 @@ async def batch_import_oauth(
raise InvalidRequestException("未找到有效的 Token 数据") raise InvalidRequestException("未找到有效的 Token 数据")
api_formats = _get_provider_api_formats(provider) api_formats = _get_provider_api_formats(provider)
proxy_config = getattr(provider, "proxy", None)
token_url = template.oauth.token_url token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url is_json = "anthropic.com" in token_url
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else "" scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
@@ -1550,6 +1608,7 @@ async def batch_import_oauth(
auth_config=auth_config, auth_config=auth_config,
api_formats=api_formats, api_formats=api_formats,
flush_only=True, flush_only=True,
proxy=key_proxy,
) )
results.append( results.append(
@@ -1599,8 +1658,15 @@ async def _batch_import_kiro_internal(
provider: Provider, provider: Provider,
raw_credentials: str, raw_credentials: str,
db: Session, db: Session,
proxy_config: dict[str, Any] | None = None,
key_proxy: dict[str, Any] | None = None,
) -> BatchImportResponse: ) -> BatchImportResponse:
"""Kiro 批量导入内部实现(供通用端点调用)。""" """Kiro 批量导入内部实现(供通用端点调用)。
Args:
proxy_config: 本次操作使用的代理配置(已由调用方解析)
key_proxy: 需要保存到 Key 上的代理配置
"""
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.token_manager import refresh_access_token from src.services.provider.adapters.kiro.token_manager import refresh_access_token
@@ -1611,8 +1677,6 @@ async def _batch_import_kiro_internal(
api_formats = _get_provider_api_formats(provider) api_formats = _get_provider_api_formats(provider)
proxy_config = getattr(provider, "proxy", None)
results: list[BatchImportResultItem] = [] results: list[BatchImportResultItem] = []
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
@@ -1675,6 +1739,7 @@ async def _batch_import_kiro_internal(
auth_config=new_cfg.to_dict(), auth_config=new_cfg.to_dict(),
api_formats=api_formats, api_formats=api_formats,
flush_only=True, flush_only=True,
proxy=key_proxy,
) )
results.append( results.append(

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import copy import copy
import json import json
import re
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
@@ -33,6 +34,7 @@ from src.core.api_format import (
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.models.endpoint_models import parse_re_flags
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint from src.models.database import ProviderAPIKey, ProviderEndpoint
@@ -215,55 +217,103 @@ def build_test_request_body(
# ============================================================================== # ==============================================================================
def _parse_path(path: str) -> list[str]: # 路径段类型str 表示 dict keyint 表示数组索引
PathSegment = str | int
def _parse_path(path: str) -> list[PathSegment]:
""" """
解析点号路径,支持转义(用 \\. 解析路径,支持点号分隔、转义和数组索引。
表示字面量点号)。
Examples: Examples:
"metadata.user.name" -> ["metadata", "user", "name"] "metadata.user.name" -> ["metadata", "user", "name"]
"config\\.v1.enabled" -> ["config.v1", "enabled"] "config\\.v1.enabled" -> ["config.v1", "enabled"]
"messages[0].content" -> ["messages", 0, "content"]
"data[0].items[2].name" -> ["data", 0, "items", 2, "name"]
"messages[-1]" -> ["messages", -1]
"matrix[0][1]" -> ["matrix", 0, 1]
约束: 约束:
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。 - 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。 - 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
- 数组索引必须是整数(支持负数索引)。
""" """
raw = (path or "").strip() raw = (path or "").strip()
if not raw: if not raw:
return [] return []
parts: list[str] = [] parts: list[PathSegment] = []
current: list[str] = [] current: list[str] = []
expect_key = True # 是否期望下一个片段是 dict key
i = 0 i = 0
while i < len(raw): while i < len(raw):
ch = raw[i] ch = raw[i]
# 转义点号:\\.
if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".": if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".":
current.append(".") current.append(".")
expect_key = False
i += 2 i += 2
continue continue
# 点号分隔符
if ch == ".": if ch == ".":
if not current: if current:
parts.append("".join(current))
current = []
elif expect_key:
# 空段(如 ".a" 或 "a..b"
return [] return []
parts.append("".join(current)) expect_key = True
current = []
i += 1 i += 1
continue continue
# 数组索引:[N]
if ch == "[":
# 先将当前累积的 key 入栈
if current:
parts.append("".join(current))
current = []
# 查找闭合括号
j = i + 1
while j < len(raw) and raw[j] != "]":
j += 1
if j >= len(raw):
return [] # 未闭合的括号
index_str = raw[i + 1 : j].strip()
if not index_str:
return [] # 空索引
try:
idx = int(index_str)
except ValueError:
return [] # 非整数索引
parts.append(idx)
expect_key = False
i = j + 1
continue
current.append(ch) current.append(ch)
expect_key = False
i += 1 i += 1
if not current: # 收尾:将剩余的 key 入栈
if current:
parts.append("".join(current))
elif expect_key:
# 尾部悬挂的点号(如 "a."
return [] return []
parts.append("".join(current)) return parts if parts else []
return parts
def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]: def _get_nested_value(obj: Any, path: str) -> tuple[bool, Any]:
""" """
获取嵌套值 获取嵌套值,支持 dict 和 list 混合遍历
Returns: Returns:
(found, value) - found 为 True 时 value 有效 (found, value) - found 为 True 时 value 有效
@@ -273,43 +323,92 @@ def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]:
return False, None return False, None
current: Any = obj current: Any = obj
for key in parts: for segment in parts:
if isinstance(current, dict) and key in current: if isinstance(segment, int):
current = current[key] if isinstance(current, list):
try:
current = current[segment]
except IndexError:
return False, None
else:
return False, None
else: else:
return False, None if isinstance(current, dict) and segment in current:
current = current[segment]
else:
return False, None
return True, current return True, current
def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool: def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool:
""" """
设置嵌套值,自动创建中间层级 设置嵌套值,支持 dict 和 list 混合遍历
当中间层存在但不是 dict 时,会覆盖为 dict 后继续写入(覆写语义) - dict 中间层:下一段为 str key 时自动创建(覆写语义);下一段为 int 时要求已存在 list
- list 中间层:必须已存在且索引有效。
- list 元素赋值:要求索引在范围内。
Returns: Returns:
True: 写入成功 True: 写入成功
False: 路径无效(空/含空段等) False: 路径无效或结构不匹配
""" """
parts = _parse_path(path) parts = _parse_path(path)
if not parts: if not parts:
return False return False
current: dict[str, Any] = obj current: Any = obj
for key in parts[:-1]: for i in range(len(parts) - 1):
next_val = current.get(key) segment = parts[i]
if not isinstance(next_val, dict): next_segment = parts[i + 1]
next_val = {}
current[key] = next_val
current = next_val
current[parts[-1]] = value if isinstance(segment, int):
return True # 遍历数组元素
if not isinstance(current, list):
return False
try:
current = current[segment]
except IndexError:
return False
else:
# 遍历 dict key
if not isinstance(current, dict):
return False
child = current.get(segment)
if isinstance(next_segment, int):
# 下一段是数组索引 → child 必须已经是 list
if not isinstance(child, list):
return False
current = child
else:
# 下一段是 dict key → 自动创建 dict覆写语义
if not isinstance(child, dict):
child = {}
current[segment] = child
current = child
# 写入最终值
last = parts[-1]
if isinstance(last, int):
if not isinstance(current, list):
return False
try:
current[last] = value
return True
except IndexError:
return False
else:
if not isinstance(current, dict):
return False
current[last] = value
return True
def _delete_nested_value(obj: dict[str, Any], path: str) -> bool: def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
""" """
删除嵌套值 删除嵌套值,支持 dict 和 list 混合遍历
对于 list 元素,使用 del 删除(会移动后续元素的索引)。
Returns: Returns:
True: 删除成功 True: 删除成功
@@ -320,23 +419,40 @@ def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
return False return False
current: Any = obj current: Any = obj
for key in parts[:-1]: for segment in parts[:-1]:
if isinstance(current, dict) and key in current: if isinstance(segment, int):
current = current[key] if isinstance(current, list):
try:
current = current[segment]
except IndexError:
return False
else:
return False
else: else:
return False if isinstance(current, dict) and segment in current:
if not isinstance(current, dict): current = current[segment]
return False else:
return False
if isinstance(current, dict) and parts[-1] in current: last = parts[-1]
del current[parts[-1]] if isinstance(last, int):
return True if isinstance(current, list):
return False try:
del current[last]
return True
except IndexError:
return False
return False
else:
if isinstance(current, dict) and last in current:
del current[last]
return True
return False
def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool: def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool:
""" """
重命名嵌套值(移动到新路径) 重命名嵌套值(移动到新路径),支持 dict 和 list 混合遍历
Returns: Returns:
True: 重命名成功 True: 重命名成功
@@ -354,11 +470,39 @@ def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> b
if not found: if not found:
return False return False
# 先 set 再 delete避免 set 失败时源值已被删除导致数据丢失
if not _set_nested_value(obj, dst, value):
return False
_delete_nested_value(obj, src) _delete_nested_value(obj, src)
_set_nested_value(obj, dst, value)
return True return True
def _is_protected_path(parts: list[PathSegment], protected_lower: frozenset[str]) -> bool:
"""检查路径的顶层 key 是否为受保护字段int 索引不可能是受保护字段)"""
if not parts:
return False
first = parts[0]
return isinstance(first, str) and first.lower() in protected_lower
def _extract_path(
rule: dict[str, Any],
protected_lower: frozenset[str],
key: str = "path",
) -> str | None:
"""从规则中提取并校验 path 字段,返回 strip 后的路径或 None无效/受保护时)。"""
raw = rule.get(key, "")
if not isinstance(raw, str):
return None
path = raw.strip()
parts = _parse_path(path)
if not parts:
return None
if _is_protected_path(parts, protected_lower):
return None
return path
def apply_body_rules( def apply_body_rules(
body: dict[str, Any], body: dict[str, Any],
rules: list[dict[str, Any]], rules: list[dict[str, Any]],
@@ -370,11 +514,19 @@ def apply_body_rules(
路径语法: 路径语法:
- 使用点号分隔层级metadata.user.name - 使用点号分隔层级metadata.user.name
- 转义字面量点号config\\.v1.enabled -> key "config.v1" 下的 "enabled" - 转义字面量点号config\\.v1.enabled -> key "config.v1" 下的 "enabled"
- 使用方括号访问数组元素messages[0].content
- 支持多层嵌套data[0].items[2].name
- 支持负数索引messages[-1]
- 支持连续数组索引matrix[0][1]
支持的规则类型: 支持的规则类型:
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123} - set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"} - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"} - rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
- append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
- insert: 在数组指定位置插入元素 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
- regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "messages[0].content",
"pattern": "\\bfoo\\b", "replacement": "bar", "flags": "i", "count": 0}
Args: Args:
body: 原始请求体 body: 原始请求体
@@ -387,7 +539,7 @@ def apply_body_rules(
if not rules: if not rules:
return body return body
# 深拷贝,避免修改原始数据(尤其是嵌套 dict # 深拷贝,避免修改原始数据(尤其是嵌套 dict/list
result = copy.deepcopy(body) result = copy.deepcopy(body)
protected = protected_keys or PROTECTED_BODY_FIELDS protected = protected_keys or PROTECTED_BODY_FIELDS
protected_lower = frozenset(str(k).lower() for k in protected) protected_lower = frozenset(str(k).lower() for k in protected)
@@ -402,27 +554,14 @@ def apply_body_rules(
action = action.strip().lower() action = action.strip().lower()
if action == "set": if action == "set":
raw_path = rule.get("path", "") path = _extract_path(rule, protected_lower)
if not isinstance(raw_path, str): if not path:
continue continue
path = raw_path.strip() _set_nested_value(result, path, rule.get("value"))
value = rule.get("value")
parts = _parse_path(path)
if not parts:
continue
if parts[0].lower() in protected_lower:
continue
_set_nested_value(result, path, value)
elif action == "drop": elif action == "drop":
raw_path = rule.get("path", "") path = _extract_path(rule, protected_lower)
if not isinstance(raw_path, str): if not path:
continue
path = raw_path.strip()
parts = _parse_path(path)
if not parts:
continue
if parts[0].lower() in protected_lower:
continue continue
_delete_nested_value(result, path) _delete_nested_value(result, path)
@@ -441,11 +580,62 @@ def apply_body_rules(
continue continue
# 受保护字段只检查顶层 key # 受保护字段只检查顶层 key
if from_parts[0].lower() in protected_lower or to_parts[0].lower() in protected_lower: if _is_protected_path(from_parts, protected_lower) or _is_protected_path(
to_parts, protected_lower
):
continue continue
_rename_nested_value(result, from_path, to_path) _rename_nested_value(result, from_path, to_path)
elif action == "append":
path = _extract_path(rule, protected_lower)
if not path:
continue
found, target = _get_nested_value(result, path)
if not found or not isinstance(target, list):
continue
target.append(rule.get("value"))
elif action == "insert":
path = _extract_path(rule, protected_lower)
if not path:
continue
index = rule.get("index")
if not isinstance(index, int):
continue
found, target = _get_nested_value(result, path)
if not found or not isinstance(target, list):
continue
target.insert(index, rule.get("value"))
elif action == "regex_replace":
path = _extract_path(rule, protected_lower)
if not path:
continue
pattern = rule.get("pattern")
replacement = rule.get("replacement", "")
if not isinstance(pattern, str) or not isinstance(replacement, str):
continue
if not pattern:
continue
flags_raw = rule.get("flags", "")
re_flags = parse_re_flags(flags_raw if isinstance(flags_raw, str) else "")
count = rule.get("count", 0)
if not isinstance(count, int) or count < 0:
count = 0
found, current_val = _get_nested_value(result, path)
if not found or not isinstance(current_val, str):
continue
try:
new_val = re.compile(pattern, re_flags).sub(replacement, current_val, count=count)
_set_nested_value(result, path, new_val)
except re.error:
continue # 正则表达式无效,跳过
return result return result

View File

@@ -22,13 +22,123 @@ HeaderRule = dict[str, Any]
# ========== Body Rule 类型定义 ========== # ========== Body Rule 类型定义 ==========
# 请求体规则支持种操作: # 请求体规则支持种操作:
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}} # - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"} # - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"} # - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
# 实际验证在 request_builder.py 的 apply_body_rules 中处理 # - append: 向数组追加元素 {"action": "append", "path": "messages", "value": {...}}
# - insert: 在数组指定位置插入 {"action": "insert", "path": "messages", "index": 0, "value": {...}}
# - regex_replace: 正则替换字符串值 {"action": "regex_replace", "path": "...", "pattern": "...", "replacement": "..."}
# 路径语法支持数组索引messages[0].content, data[-1], matrix[0][1]
# 运行时处理在 request_builder.py 的 apply_body_rules 中;结构校验见 _validate_body_rules
BodyRule = dict[str, Any] BodyRule = dict[str, Any]
# body_rules 允许的 action 集合
_BODY_RULE_ACTIONS: frozenset[str] = frozenset(
{"set", "drop", "rename", "append", "insert", "regex_replace"}
)
# regex_replace 允许的 flags 字符
_REGEX_FLAG_CHARS: frozenset[str] = frozenset({"i", "m", "s"})
def parse_re_flags(flags_str: str) -> int:
"""将 flags 字符串i/m/s转换为 re 标志位。
供 endpoint_models 校验和 request_builder 运行时共用。
"""
result = 0
for f in flags_str:
if f == "i":
result |= re.IGNORECASE
elif f == "m":
result |= re.MULTILINE
elif f == "s":
result |= re.DOTALL
return result
def _validate_body_rules(rules: list[BodyRule]) -> list[BodyRule]:
"""校验 body_rules 列表的结构和正则合法性。
校验项:
- 每条规则必须是 dict 且包含合法 action
- 需要 path 的 actionset/drop/append/insert/regex_replace必须提供非空 path 字符串
- rename 必须提供非空 from / to 字符串
- insert 的 index 必须为整数
- regex_replace 的 pattern 必须能通过 re.compile 编译flags 仅允许 i/m/s
"""
for idx, rule in enumerate(rules):
if not isinstance(rule, dict):
raise ValueError(f"body_rules[{idx}]: 规则必须是 JSON 对象")
action = rule.get("action")
if not isinstance(action, str) or action.strip().lower() not in _BODY_RULE_ACTIONS:
raise ValueError(
f"body_rules[{idx}]: action 必须是 {sorted(_BODY_RULE_ACTIONS)} 之一,"
f"当前值: {action!r}"
)
action = action.strip().lower()
# ---------- path 校验 ----------
if action in {"set", "drop", "append", "insert", "regex_replace"}:
path = rule.get("path")
if not isinstance(path, str) or not path.strip():
raise ValueError(f"body_rules[{idx}]: action={action!r} 必须提供非空 path")
# ---------- rename 校验 ----------
if action == "rename":
from_val = rule.get("from")
to_val = rule.get("to")
if not isinstance(from_val, str) or not from_val.strip():
raise ValueError(f"body_rules[{idx}]: rename 必须提供非空 from")
if not isinstance(to_val, str) or not to_val.strip():
raise ValueError(f"body_rules[{idx}]: rename 必须提供非空 to")
# ---------- insert 校验 ----------
if action == "insert":
index = rule.get("index")
if not isinstance(index, int) or isinstance(index, bool):
raise ValueError(f"body_rules[{idx}]: insert 的 index 必须为整数")
# ---------- regex_replace 校验 ----------
if action == "regex_replace":
pattern = rule.get("pattern")
if not isinstance(pattern, str) or not pattern:
raise ValueError(f"body_rules[{idx}]: regex_replace 必须提供非空 pattern 字符串")
replacement = rule.get("replacement", "")
if not isinstance(replacement, str):
raise ValueError(f"body_rules[{idx}]: regex_replace 的 replacement 必须为字符串")
# 校验 flags
flags_str = rule.get("flags", "")
re_flags = 0
if isinstance(flags_str, str) and flags_str:
invalid_flags = set(flags_str) - _REGEX_FLAG_CHARS
if invalid_flags:
raise ValueError(
f"body_rules[{idx}]: regex_replace 的 flags 仅允许 "
f"{''.join(sorted(_REGEX_FLAG_CHARS))}"
f"非法字符: {''.join(sorted(invalid_flags))}"
)
re_flags = parse_re_flags(flags_str)
# 尝试编译正则,捕获语法错误
try:
re.compile(pattern, re_flags)
except re.error as e:
raise ValueError(
f"body_rules[{idx}]: regex_replace 的 pattern 不是合法正则表达式: {e}"
)
# 校验 count
count = rule.get("count", 0)
if not isinstance(count, int) or count < 0:
raise ValueError(f"body_rules[{idx}]: regex_replace 的 count 必须为非负整数")
return rules
# ========== ProviderEndpoint CRUD ========== # ========== ProviderEndpoint CRUD ==========
@@ -55,7 +165,7 @@ class ProviderEndpointCreate(BaseModel):
# 请求体配置 # 请求体配置
body_rules: list[BodyRule] | None = Field( body_rules: list[BodyRule] | None = Field(
default=None, default=None,
description="请求体规则列表,支持 set/drop/rename 操作", description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
) )
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数") max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
@@ -93,6 +203,14 @@ class ProviderEndpointCreate(BaseModel):
return v.rstrip("/") # 移除末尾斜杠 return v.rstrip("/") # 移除末尾斜杠
@field_validator("body_rules")
@classmethod
def validate_body_rules(cls, v: list[BodyRule] | None) -> list[BodyRule] | None:
"""校验 body_rules 结构和正则合法性"""
if v is None:
return v
return _validate_body_rules(v)
class ProviderEndpointUpdate(BaseModel): class ProviderEndpointUpdate(BaseModel):
"""更新 Endpoint 请求""" """更新 Endpoint 请求"""
@@ -111,7 +229,7 @@ class ProviderEndpointUpdate(BaseModel):
# 请求体配置 # 请求体配置
body_rules: list[BodyRule] | None = Field( body_rules: list[BodyRule] | None = Field(
default=None, default=None,
description="请求体规则列表,支持 set/drop/rename 操作", description="请求体规则列表,支持 set/drop/rename/append/insert/regex_replace 操作",
) )
max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数") max_retries: int | None = Field(default=None, ge=0, le=10, description="最大重试次数")
@@ -137,6 +255,14 @@ class ProviderEndpointUpdate(BaseModel):
return v.rstrip("/") # 移除末尾斜杠 return v.rstrip("/") # 移除末尾斜杠
@field_validator("body_rules")
@classmethod
def validate_body_rules(cls, v: list[BodyRule] | None) -> list[BodyRule] | None:
"""校验 body_rules 结构和正则合法性"""
if v is None:
return v
return _validate_body_rules(v)
class ProviderEndpointResponse(BaseModel): class ProviderEndpointResponse(BaseModel):
"""Endpoint 响应""" """Endpoint 响应"""

View File

@@ -88,8 +88,8 @@ def build_codex_url(
async def enrich_codex( async def enrich_codex(
auth_config: dict[str, Any], auth_config: dict[str, Any],
token_response: dict[str, Any], token_response: dict[str, Any],
access_token: str, access_token: str, # noqa: ARG001
proxy_config: dict[str, Any] | None, proxy_config: dict[str, Any] | None, # noqa: ARG001
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id.""" """Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
from src.core.provider_oauth_utils import parse_codex_id_token from src.core.provider_oauth_utils import parse_codex_id_token
@@ -100,17 +100,11 @@ async def enrich_codex(
bool(id_token), bool(id_token),
list(token_response.keys()), list(token_response.keys()),
) )
codex_info = parse_codex_id_token(str(id_token) if id_token else None) # parse_codex_id_token 仅返回非空有效字段,直接 update 即可
codex_info = parse_codex_id_token(id_token)
if codex_info: if codex_info:
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys())) logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
if codex_info.get("email"): auth_config.update(codex_info)
auth_config["email"] = codex_info["email"]
if codex_info.get("account_id"):
auth_config["account_id"] = codex_info["account_id"]
if codex_info.get("plan_type"):
auth_config["plan_type"] = codex_info["plan_type"]
if codex_info.get("user_id"):
auth_config["user_id"] = codex_info["user_id"]
return auth_config return auth_config