Compare commits
19 Commits
fe0f2a7e88
..
team
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a68ff6162 | |||
| 6b2d78fe56 | |||
| 9c604460b1 | |||
| 8d34f8d6fe | |||
| 6d1d0f3b6b | |||
| 24529189d9 | |||
| 000162b1b1 | |||
| 6662ea5e04 | |||
| 5789d50e9e | |||
| ca3d89751d | |||
| 2bc857cf88 | |||
| a9ff7e1c94 | |||
| 51d4651c6c | |||
| e112f3af12 | |||
| 73e53c2333 | |||
| 470e49b850 | |||
| d426781e47 | |||
| b80f0759a5 | |||
| b83c6d9786 |
@@ -0,0 +1,8 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
mariadb:
|
||||
image: mariadb
|
||||
container_name: mysql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
@@ -17,9 +18,4 @@ services:
|
||||
MYSQL_DATABASE: openteam
|
||||
MYSQL_USER: openteam
|
||||
MYSQL_PASSWORD: openteam
|
||||
|
||||
# adminer:
|
||||
# image: adminer
|
||||
# restart: always
|
||||
# ports:
|
||||
# - 8080:8080
|
||||
|
||||
|
||||
@@ -20,8 +20,3 @@ services:
|
||||
volumes:
|
||||
- $PWD/pgdata:/var/lib/postgresql/data
|
||||
|
||||
# adminer:
|
||||
# image: adminer
|
||||
# restart: always
|
||||
# ports:
|
||||
# - 8080:8080
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
sqlite-web:
|
||||
image: vaalacat/sqlite-web
|
||||
ports:
|
||||
- 8800:8080
|
||||
volumes:
|
||||
- $PWD/db:/data
|
||||
environment:
|
||||
- SQLITE_DATABASE=openteam.db
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg max-w-sm mx-auto backdrop-blur-xl glass">
|
||||
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg mt-2 max-w-sm mx-auto backdrop-blur-xl glass">
|
||||
|
||||
<div class="p-3 bg-white rounded-lg shadow-inner cursor-pointer" @click="toggleQRCode">
|
||||
<qrcode-vue :value="currentValue" :size="size" level="H" />
|
||||
|
||||
@@ -11,7 +11,7 @@ if (import.meta.env.DEV) { // Vite 的方式判断开发环境
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
timeout: 6000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -12,17 +12,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span>Error! {{ error }}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" @click="error = null">X</button>
|
||||
</div>
|
||||
|
||||
<div class="card border border-base-300/40 shadow-sm">
|
||||
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
|
||||
<div class="space-y-4">
|
||||
@@ -100,13 +89,13 @@
|
||||
class="input input-sm input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<!-- <div class="form-control">
|
||||
<label for="deployment_name" class="label">
|
||||
<span class="label-text">Deployment Name</span>
|
||||
</label>
|
||||
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
|
||||
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="form-control">
|
||||
<label for="api_secret" class="label">
|
||||
@@ -147,7 +136,7 @@
|
||||
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
|
||||
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
||||
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
|
||||
placeholder="Please input" @change="onchange_supportmodel"/>
|
||||
placeholder="Please input" @change="onchange_supportmodel" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
@@ -165,10 +154,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" @click="error = null">X</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 items-center gap-2">
|
||||
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
|
||||
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success" :disabled="!isFormValid">
|
||||
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success"
|
||||
:disabled="!isFormValid">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
@@ -200,12 +202,12 @@ const newApiKey = ref({
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
deployment_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
})
|
||||
|
||||
@@ -217,12 +219,12 @@ const resetNewApiKey = () => {
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
deployment_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
}
|
||||
}
|
||||
@@ -249,7 +251,7 @@ const apiKeyImageMap = {
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
|
||||
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype) => {
|
||||
@@ -261,7 +263,7 @@ const createApiKey = async () => {
|
||||
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
try {
|
||||
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
class="input input-sm input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<!-- <div class="form-control">
|
||||
<label for="deployment_name" class="label">
|
||||
<span class="label-text">Deployment Name</span>
|
||||
</label>
|
||||
<input id="deployment_name" type="text" v-model="key.deployment_name"
|
||||
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="form-control">
|
||||
<label for="api_secret" class="label">
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
|
||||
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
|
||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||
|
||||
@@ -24,86 +24,81 @@
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Tokens</h3>
|
||||
<div v-if="user.tokens">
|
||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||
<th class="px-2 py-3">Token</th>
|
||||
<th class="px-2 py-3">Status</th>
|
||||
<!-- <th class="px-2 py-3">Key</th> -->
|
||||
<th class="px-2 py-3">Expired</th>
|
||||
<th class="px-2 py-3">Quota</th>
|
||||
<th class="px-2 py-3">Used</th>
|
||||
<th class="text-right px-2 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-xs"
|
||||
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
|
||||
@change="updateStatus(token)" />
|
||||
</td>
|
||||
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
|
||||
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
|
||||
<td class="px-2 py-3">
|
||||
<template v-if="token.unlimited_quota">
|
||||
<Infinity />
|
||||
</template>
|
||||
<template v-else>{{ token.quota }}</template>
|
||||
</td>
|
||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||
<td class="text-right px-1 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
|
||||
<EyeIcon class="w-5 h-5 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="md:tooltip" data-tip="clean used">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
|
||||
aria-label="Revoke token">
|
||||
<Eraser class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||
<th class="px-2 py-3">Token</th>
|
||||
<th class="px-2 py-3">Status</th>
|
||||
<!-- <th class="px-2 py-3">Key</th> -->
|
||||
<th class="px-2 py-3">Expired</th>
|
||||
<th class="px-2 py-3">Quota</th>
|
||||
<th class="px-2 py-3">Used</th>
|
||||
<th class="text-right px-2 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-xs" :class="token.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="token.active" @change="updateStatus(token)" />
|
||||
</td>
|
||||
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
|
||||
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
|
||||
<td class="px-2 py-3">
|
||||
<template v-if="token.unlimited_quota">
|
||||
<Infinity />
|
||||
</template>
|
||||
<template v-else>{{ token.quota }}</template>
|
||||
</td>
|
||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||
<td class="text-right px-1 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
|
||||
<EyeIcon class="w-5 h-5 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
<div class="md:tooltip" data-tip="clean used">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
|
||||
<button v-if="token.name !== 'default'"
|
||||
class="btn btn-ghost btn-xs btn-square text-error items-center" @click="confirmRevokeToken(token)"
|
||||
aria-label="Revoke token">
|
||||
<Eraser class="w-5 h-5" />
|
||||
<TrashIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error items-center"
|
||||
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
||||
<TrashIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<dialog id="myToken" class="modal" ref="tokenRef">
|
||||
<div class="modal-box px-0 sm:px-8">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<dialog id="myToken" class="modal" ref="tokenRef">
|
||||
<div class="modal-box px-0 sm:px-8">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<QRCodeCard :value="qrCodeValue" :size="120" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>关闭</button>
|
||||
</form>
|
||||
token
|
||||
<QRCodeCard
|
||||
:value="qrCodeValue"
|
||||
:size="120" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>关闭</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -205,22 +200,6 @@ const viewToken = (token) => {
|
||||
|
||||
const qrCodeValue = ref('');
|
||||
|
||||
// 监听showTokenModel的变化,控制模态框的显示和隐藏
|
||||
// watch(showTokenModel, (newValue) => {
|
||||
// const dialog = tokenRef.value;
|
||||
// if (dialog) {
|
||||
// if (newValue) {
|
||||
// if (!dialog.hasAttribute('open')) {
|
||||
// dialog.showModal();
|
||||
// }
|
||||
// } else {
|
||||
// if (dialog.hasAttribute('open')) {
|
||||
// dialog.close();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref(null);
|
||||
|
||||
@@ -125,13 +125,13 @@
|
||||
<div class="flex gap-1">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
|
||||
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
|
||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
||||
@click="confirmDeleteUser(user)">
|
||||
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
|
||||
<TrashIcon class="w-4 h-4 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ func (a Api) CreateApiKey(c *gin.Context) {
|
||||
}
|
||||
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
|
||||
sma, err := utils.FetchKeyModel(a.db, newkey)
|
||||
if err == nil {
|
||||
if err == nil && len(sma) > 0 {
|
||||
newkey.SupportModelsArray = sma
|
||||
var buf = new(bytes.Buffer)
|
||||
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
|
||||
|
||||
@@ -4,15 +4,23 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"opencatd-open/internal/dto"
|
||||
"opencatd-open/internal/model"
|
||||
"opencatd-open/llm"
|
||||
"opencatd-open/llm/claude/v2"
|
||||
"opencatd-open/llm/google/v2"
|
||||
"opencatd-open/llm/openai_compatible"
|
||||
"opencatd-open/pkg/tokenizer"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Proxy) ChatHandler(c *gin.Context) {
|
||||
user := c.MustGet("user").(*model.User)
|
||||
if user == nil {
|
||||
dto.WrapErrorAsOpenAI(c, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var chatreq llm.ChatRequest
|
||||
if err := c.ShouldBindJSON(&chatreq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -35,10 +43,10 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
|
||||
fallthrough
|
||||
default:
|
||||
llm, err = openai_compatible.NewOpenAICompatible(h.apikey)
|
||||
if err != nil {
|
||||
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !chatreq.Stream {
|
||||
@@ -57,4 +65,13 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
|
||||
c.SSEvent("", data)
|
||||
}
|
||||
}
|
||||
|
||||
llmusage := llm.GetTokenUsage()
|
||||
llmusage.User = user
|
||||
llmusage.TokenID = c.GetInt64("token_id")
|
||||
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens+llmusage.ToolsTokens, llmusage.CompletionTokens)
|
||||
|
||||
h.SendUsage(llmusage)
|
||||
defer fmt.Println("cost:", cost, "prompt_tokens:", llmusage.PromptTokens, "completion_tokens:", llmusage.CompletionTokens, "total_tokens:", llmusage.TotalTokens)
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"opencatd-open/internal/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (p *Proxy) HandleModels(c *gin.Context) {
|
||||
models, err := p.getModelCache()
|
||||
if err != nil {
|
||||
dto.Fail(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
type _model struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
var ms []_model
|
||||
for _, model := range models {
|
||||
ms = append(ms, _model{ID: model})
|
||||
}
|
||||
dto.Success(c, ms)
|
||||
}
|
||||
|
||||
func (p *Proxy) setModelCache() error {
|
||||
apikeys, err := p.apiKeyDao.FindKeys(nil)
|
||||
models := make(map[string]bool)
|
||||
if err == nil && len(apikeys) > 0 {
|
||||
for _, k := range apikeys {
|
||||
if len(k.SupportModelsArray) > 0 {
|
||||
for _, sm := range k.SupportModelsArray {
|
||||
models[sm] = true
|
||||
}
|
||||
} else {
|
||||
var sma []string
|
||||
json.Unmarshal([]byte(*k.SupportModels), &sma) // nolint:errCheck
|
||||
for _, sm := range sma {
|
||||
models[sm] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("empty data")
|
||||
}
|
||||
var support_models []string
|
||||
for m, _ := range models {
|
||||
support_models = append(support_models, m)
|
||||
}
|
||||
return p.cache.Set("models", support_models)
|
||||
}
|
||||
|
||||
func (p *Proxy) getModelCache() ([]string, error) {
|
||||
models, err := p.cache.Get("models")
|
||||
return models.([]string), err
|
||||
}
|
||||
@@ -12,17 +12,20 @@ import (
|
||||
"net/url"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/model"
|
||||
"opencatd-open/internal/utils"
|
||||
"opencatd-open/llm"
|
||||
"opencatd-open/pkg/config"
|
||||
"opencatd-open/pkg/tokenizer"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluele/gcache"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
"github.com/tidwall/gjson"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Proxy struct {
|
||||
@@ -30,9 +33,10 @@ type Proxy struct {
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
wg *sync.WaitGroup
|
||||
usageChan chan *model.Usage // 用于异步处理的channel
|
||||
usageChan chan *llm.TokenUsage // 用于异步处理的channel
|
||||
apikey *model.ApiKey
|
||||
httpClient *http.Client
|
||||
cache gcache.Cache
|
||||
|
||||
userDAO *dao.UserDAO
|
||||
apiKeyDao *dao.ApiKeyDAO
|
||||
@@ -52,13 +56,15 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
|
||||
client.Transport = tr
|
||||
}
|
||||
}
|
||||
|
||||
np := &Proxy{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
wg: wg,
|
||||
httpClient: client,
|
||||
usageChan: make(chan *model.Usage, cfg.UsageChanSize),
|
||||
cache: gcache.New(1).Build(),
|
||||
usageChan: make(chan *llm.TokenUsage, cfg.UsageChanSize),
|
||||
userDAO: userDAO,
|
||||
apiKeyDao: apiKeyDAO,
|
||||
tokenDAO: tokenDAO,
|
||||
@@ -68,7 +74,7 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
|
||||
|
||||
go np.ProcessUsage()
|
||||
go np.ScheduleTask()
|
||||
|
||||
np.setModelCache()
|
||||
return np
|
||||
}
|
||||
|
||||
@@ -77,9 +83,13 @@ func (p *Proxy) HandleProxy(c *gin.Context) {
|
||||
p.ChatHandler(c)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
|
||||
p.ProxyClaude(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) SendUsage(usage *model.Usage) {
|
||||
func (p *Proxy) SendUsage(usage *llm.TokenUsage) {
|
||||
select {
|
||||
case p.usageChan <- usage:
|
||||
default:
|
||||
@@ -135,46 +145,90 @@ func (p *Proxy) ProcessUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) Do(usage *model.Usage) error {
|
||||
func (p *Proxy) Do(llmusage *llm.TokenUsage) error {
|
||||
err := p.db.Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
today, _ := time.Parse("2006-01-02", now.Format("2006-01-02"))
|
||||
|
||||
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens, llmusage.CompletionTokens)
|
||||
token, err := p.tokenDAO.GetByID(p.ctx, llmusage.TokenID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usage := &model.Usage{
|
||||
UserID: llmusage.User.ID,
|
||||
TokenID: llmusage.TokenID,
|
||||
Date: now,
|
||||
Model: llmusage.Model,
|
||||
Stream: llmusage.Stream,
|
||||
PromptTokens: llmusage.PromptTokens,
|
||||
CompletionTokens: llmusage.CompletionTokens,
|
||||
TotalTokens: llmusage.TotalTokens,
|
||||
Cost: fmt.Sprintf("%.8f", cost),
|
||||
}
|
||||
// 1. 记录使用记录
|
||||
if err := tx.WithContext(p.ctx).Create(usage).Error; err != nil {
|
||||
return fmt.Errorf("create usage error: %w", err)
|
||||
}
|
||||
|
||||
// 2. 更新每日统计(upsert 操作)
|
||||
dailyUsage := model.DailyUsage{
|
||||
UserID: usage.UserID,
|
||||
TokenID: usage.TokenID,
|
||||
Capability: usage.Capability,
|
||||
Date: time.Date(usage.Date.Year(), usage.Date.Month(), usage.Date.Day(), 0, 0, 0, 0, usage.Date.Location()),
|
||||
Model: usage.Model,
|
||||
Stream: usage.Stream,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
Cost: usage.Cost,
|
||||
}
|
||||
|
||||
// 使用 OnConflict 实现 upsert
|
||||
if err := tx.WithContext(p.ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "token_id"}, {Name: "capability"}, {Name: "date"}}, // 唯一键
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"prompt_tokens": gorm.Expr("prompt_tokens + ?", usage.PromptTokens),
|
||||
"completion_tokens": gorm.Expr("completion_tokens + ?", usage.CompletionTokens),
|
||||
"total_tokens": gorm.Expr("total_tokens + ?", usage.TotalTokens),
|
||||
"cost": gorm.Expr("cost + ?", usage.Cost),
|
||||
}),
|
||||
}).Create(&dailyUsage).Error; err != nil {
|
||||
return fmt.Errorf("upsert daily usage error: %w", err)
|
||||
// 2. 更新每日统计
|
||||
var dailyUsage model.DailyUsage
|
||||
result := tx.WithContext(p.ctx).Where("user_id = ? and date = ?", llmusage.User.ID, today).First(&dailyUsage)
|
||||
if result.RowsAffected == 0 {
|
||||
dailyUsage.UserID = llmusage.User.ID
|
||||
dailyUsage.TokenID = llmusage.TokenID
|
||||
dailyUsage.Date = today
|
||||
dailyUsage.Model = llmusage.Model
|
||||
dailyUsage.Stream = llmusage.Stream
|
||||
dailyUsage.PromptTokens = llmusage.PromptTokens
|
||||
dailyUsage.CompletionTokens = llmusage.CompletionTokens
|
||||
dailyUsage.TotalTokens = llmusage.TotalTokens
|
||||
dailyUsage.Cost = fmt.Sprintf("%.8f", cost)
|
||||
if err := tx.WithContext(p.ctx).Create(&dailyUsage).Error; err != nil {
|
||||
return fmt.Errorf("create daily usage error: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.WithContext(p.ctx).Model(&model.DailyUsage{}).Where("user_id = ? and date = ?", llmusage.User.ID, today).
|
||||
Updates(map[string]interface{}{
|
||||
"prompt_tokens": gorm.Expr("prompt_tokens + ?", llmusage.PromptTokens),
|
||||
"completion_tokens": gorm.Expr("completion_tokens + ?", llmusage.CompletionTokens),
|
||||
"total_tokens": gorm.Expr("total_tokens + ?", llmusage.TotalTokens),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update daily usage error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新用户额度
|
||||
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", usage.UserID).Updates(map[string]interface{}{
|
||||
"quota": gorm.Expr("quota - ?", usage.Cost),
|
||||
"used_quota": gorm.Expr("used_quota + ?", usage.Cost),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
||||
if *llmusage.User.UnlimitedQuota {
|
||||
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
||||
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
//4 . 更新token额度
|
||||
if *token.UnlimitedQuota {
|
||||
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
||||
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -184,10 +238,9 @@ func (p *Proxy) Do(usage *model.Usage) error {
|
||||
|
||||
func (p *Proxy) SelectApiKey(model string) error {
|
||||
akpikeys, err := p.apiKeyDao.FindApiKeysBySupportModel(p.db, model)
|
||||
|
||||
if err != nil || len(akpikeys) == 0 {
|
||||
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") {
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "openai"})
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "openai"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,7 +248,7 @@ func (p *Proxy) SelectApiKey(model string) error {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(model, "gemini") {
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "gemini"})
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "gemini"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,7 +256,7 @@ func (p *Proxy) SelectApiKey(model string) error {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(model, "claude") {
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "claude"})
|
||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "claude"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +323,10 @@ func (p *Proxy) ScheduleTask() {
|
||||
select {
|
||||
case <-time.After(time.Duration(p.cfg.TaskTimeInterval) * time.Minute):
|
||||
p.updateSupportModel()
|
||||
|
||||
case <-time.After(time.Hour * 12):
|
||||
if err := p.setModelCache(); err != nil {
|
||||
fmt.Println("refrash model cache err:", err)
|
||||
}
|
||||
case <-p.ctx.Done():
|
||||
fmt.Println("schedule task done")
|
||||
return
|
||||
@@ -287,6 +343,9 @@ func (p *Proxy) getOpenAISupportModels(apikey model.ApiKey) ([]string, error) {
|
||||
var supportModels []string
|
||||
var req *http.Request
|
||||
if *apikey.ApiType == "azure" {
|
||||
if strings.HasSuffix(*apikey.Endpoint, "/") {
|
||||
apikey.Endpoint = utils.ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
|
||||
}
|
||||
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("api-key", *apikey.ApiKey)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (p *Proxy) ProxyClaude(c *gin.Context) {
|
||||
fmt.Println(c.Request.URL.String())
|
||||
data, _ := io.ReadAll(c.Request.Body)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"opencatd-open/internal/dto"
|
||||
"opencatd-open/internal/model"
|
||||
@@ -142,7 +141,7 @@ func (a Api) CreateUser(c *gin.Context) {
|
||||
dto.Fail(c, 400, err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("user:%+v\n", user)
|
||||
|
||||
err = a.userService.Create(c, &user)
|
||||
if err != nil {
|
||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -90,7 +90,7 @@ func (a Api) ResetToken(c *gin.Context) {
|
||||
dto.Fail(c, http.StatusNotFound, "token not found")
|
||||
return
|
||||
}
|
||||
token.UsedQuota = utils.ToPtr(int64(0))
|
||||
token.UsedQuota = utils.ToPtr(float64(0))
|
||||
|
||||
err = a.tokenService.UpdateToken(c, token)
|
||||
if err != nil {
|
||||
|
||||
+16
-5
@@ -3,6 +3,7 @@ package dao
|
||||
import (
|
||||
"errors"
|
||||
"opencatd-open/internal/model"
|
||||
"opencatd-open/internal/utils"
|
||||
"opencatd-open/pkg/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -38,6 +39,9 @@ func (dao *ApiKeyDAO) Create(apiKey *model.ApiKey) error {
|
||||
if apiKey == nil {
|
||||
return errors.New("apiKey is nil")
|
||||
}
|
||||
if len(*apiKey.SupportModels) < 2 {
|
||||
apiKey.SupportModels = utils.ToPtr("[]")
|
||||
}
|
||||
return dao.db.Create(apiKey).Error
|
||||
}
|
||||
|
||||
@@ -87,14 +91,21 @@ func (dao *ApiKeyDAO) FindApiKeysBySupportModel(db *gorm.DB, modelName string) (
|
||||
var apiKeys []model.ApiKey
|
||||
switch dao.cfg.DB_Type {
|
||||
case "mysql":
|
||||
return nil, errors.New("not support")
|
||||
err := db.Raw(`
|
||||
SELECT *
|
||||
FROM apikeys
|
||||
WHERE active = true
|
||||
AND JSON_CONTAINS(support_models, ?, '$')`, modelName).
|
||||
Scan(&apiKeys).Error
|
||||
return apiKeys, err
|
||||
case "postgres":
|
||||
return nil, errors.New("not support")
|
||||
}
|
||||
err := db.Model(&model.ApiKey{}).
|
||||
Joins("CROSS JOIN JSON_EACH(apikeys.support_models)").
|
||||
Where("value = ?", modelName).
|
||||
Find(&apiKeys).Error
|
||||
err := db.Raw(`
|
||||
SELECT a.*
|
||||
FROM apikeys a
|
||||
JOIN json_each(a.support_models) AS je ON je.value = ?
|
||||
WHERE a.active = true`, modelName).Scan(&apiKeys).Error
|
||||
return apiKeys, err
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -212,11 +212,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "user_id"},
|
||||
{Name: "token_id"},
|
||||
{Name: "capability"},
|
||||
{Name: "date"},
|
||||
{Name: "model"},
|
||||
{Name: "stream"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(updateColumns),
|
||||
}).Create(dailyUsage).Error
|
||||
@@ -231,11 +227,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "user_id"},
|
||||
{Name: "token_id"},
|
||||
{Name: "capability"},
|
||||
{Name: "date"},
|
||||
{Name: "model"},
|
||||
{Name: "stream"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(updateColumns),
|
||||
}).Create(dailyUsage).Error
|
||||
@@ -244,8 +236,8 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
|
||||
default:
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.DailyUsage
|
||||
err := tx.Where("user_id = ? AND token_id = ? AND capability = ? AND date = ? AND model = ? AND stream = ?",
|
||||
usage.UserID, usage.TokenID, usage.Capability, date, usage.Model, usage.Stream).
|
||||
err := tx.Where("user_id = ? AND date = ?",
|
||||
usage.UserID, date).
|
||||
First(&existing).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
|
||||
+16
-16
@@ -3,14 +3,14 @@ package model
|
||||
import "github.com/lib/pq" //pq.StringArray
|
||||
|
||||
type ApiKey_PG struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
||||
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
||||
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
||||
ApiSecret *string `gorm:"column:api_secret" json:"api_secret,omitempty"`
|
||||
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
|
||||
ModelAlias *string `gorm:"column:model_alias" json:"model_alias,omitempty"`
|
||||
@@ -26,14 +26,14 @@ func (ApiKey_PG) TableName() string {
|
||||
}
|
||||
|
||||
type ApiKey struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
||||
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
||||
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
||||
AccessKey *string `gorm:"column:access_key" json:"access_key,omitempty"`
|
||||
SecretKey *string `gorm:"column:secret_key" json:"secret_key,omitempty"`
|
||||
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
|
||||
|
||||
+13
-13
@@ -2,19 +2,19 @@ package model
|
||||
|
||||
// 用户的token
|
||||
type Token struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
|
||||
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
|
||||
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
|
||||
Quota *int64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
|
||||
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
|
||||
UsedQuota *int64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
|
||||
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
|
||||
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
|
||||
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
|
||||
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
||||
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
|
||||
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
|
||||
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
|
||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
|
||||
Quota *float64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
|
||||
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
|
||||
UsedQuota *float64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
|
||||
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
|
||||
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
|
||||
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
|
||||
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
|
||||
}
|
||||
|
||||
func (Token) TableName() string {
|
||||
|
||||
+9
-42
@@ -1,11 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"opencatd-open/store"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Usage struct {
|
||||
@@ -16,9 +12,9 @@ type Usage struct {
|
||||
Date time.Time `gorm:"column:date;autoCreateTime;index:idx_date"`
|
||||
Model string `gorm:"column:model"`
|
||||
Stream bool `gorm:"column:stream"`
|
||||
PromptTokens float64 `gorm:"column:prompt_tokens"`
|
||||
CompletionTokens float64 `gorm:"column:completion_tokens"`
|
||||
TotalTokens float64 `gorm:"column:total_tokens"`
|
||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
||||
TotalTokens int `gorm:"column:total_tokens"`
|
||||
Cost string `gorm:"column:cost"`
|
||||
}
|
||||
|
||||
@@ -28,47 +24,18 @@ func (Usage) TableName() string {
|
||||
|
||||
type DailyUsage struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"`
|
||||
TokenID int64 `gorm:"column:token_id;index:idx_daily_token_id"`
|
||||
Capability string `gorm:"column:capability;uniqueIndex:idx_daily_unique,priority:2;comment:模型能力"`
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"` // uniqueIndex:idx_daily_unique,priority:1
|
||||
TokenID int64 `gorm:"column:token_id;uniqueIndex:idx_daily_unique,priority:2"`
|
||||
Capability string `gorm:"column:capability;index:idx_daily_usage_capability;comment:模型能力"`
|
||||
Date time.Time `gorm:"column:date;autoCreateTime;uniqueIndex:idx_daily_unique,priority:3"`
|
||||
Model string `gorm:"column:model"`
|
||||
Stream bool `gorm:"column:stream"`
|
||||
PromptTokens float64 `gorm:"column:prompt_tokens"`
|
||||
CompletionTokens float64 `gorm:"column:completion_tokens"`
|
||||
TotalTokens float64 `gorm:"column:total_tokens"`
|
||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
||||
TotalTokens int `gorm:"column:total_tokens"`
|
||||
Cost string `gorm:"column:cost"`
|
||||
}
|
||||
|
||||
func (DailyUsage) TableName() string {
|
||||
return "daily_usages"
|
||||
}
|
||||
|
||||
func HandleUsage(c *gin.Context) {
|
||||
fromStr := c.Query("from")
|
||||
toStr := c.Query("to")
|
||||
getMonthStartAndEnd := func() (start, end string) {
|
||||
loc, _ := time.LoadLocation("Local")
|
||||
now := time.Now().In(loc)
|
||||
|
||||
year, month, _ := now.Date()
|
||||
|
||||
startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
||||
endOfMonth := startOfMonth.AddDate(0, 1, 0)
|
||||
|
||||
start = startOfMonth.Format("2006-01-02")
|
||||
end = endOfMonth.Format("2006-01-02")
|
||||
return
|
||||
}
|
||||
if fromStr == "" || toStr == "" {
|
||||
fromStr, toStr = getMonthStartAndEnd()
|
||||
}
|
||||
|
||||
usage, err := store.QueryUsage(fromStr, toStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, usage)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *model.ApiK
|
||||
if apikey.ResourceNmae != nil {
|
||||
_key.ResourceNmae = apikey.ResourceNmae
|
||||
}
|
||||
if apikey.DeploymentName != nil {
|
||||
_key.DeploymentName = apikey.DeploymentName
|
||||
}
|
||||
if apikey.AccessKey != nil {
|
||||
_key.AccessKey = apikey.AccessKey
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"opencatd-open/internal/model"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var client = &http.Client{}
|
||||
var client = &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
func init() {
|
||||
if os.Getenv("LOCAL_PROXY") != "" {
|
||||
@@ -29,13 +30,13 @@ func FetchKeyModel(db *gorm.DB, key *model.ApiKey) ([]string, error) {
|
||||
var err error
|
||||
if *key.ApiType == "openai" || *key.ApiType == "azure" {
|
||||
supportModels, err = FetchOpenAISupportModels(db, key)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
if *key.ApiType == "claude" {
|
||||
supportModels, err = FetchClaudeSupportModels(db, key)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return supportModels, err
|
||||
}
|
||||
|
||||
@@ -47,6 +48,9 @@ func FetchOpenAISupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, erro
|
||||
var supportModels []string
|
||||
var req *http.Request
|
||||
if *apikey.ApiType == "azure" {
|
||||
if strings.HasSuffix(*apikey.Endpoint, "/") {
|
||||
apikey.Endpoint = ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
|
||||
}
|
||||
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("api-key", *apikey.ApiKey)
|
||||
|
||||
@@ -94,7 +94,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
|
||||
if chatReq.MaxTokens > 0 {
|
||||
maxTokens = chatReq.MaxTokens
|
||||
} else {
|
||||
if strings.Contains(chatReq.Model, "sonnet") || strings.Contains(chatReq.Model, "haiku") {
|
||||
if strings.Contains(chatReq.Model, "3-7") {
|
||||
maxTokens = 64000
|
||||
} else if strings.Contains(chatReq.Model, "3-5") {
|
||||
maxTokens = 8192
|
||||
} else {
|
||||
maxTokens = 4096
|
||||
@@ -111,6 +113,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.tokenUsage.Model == "" && resp.Model != "" {
|
||||
c.tokenUsage.Model = string(resp.Model)
|
||||
}
|
||||
c.tokenUsage.PromptTokens += resp.Usage.InputTokens
|
||||
c.tokenUsage.CompletionTokens += resp.Usage.OutputTokens
|
||||
c.tokenUsage.TotalTokens += resp.Usage.InputTokens + resp.Usage.OutputTokens
|
||||
|
||||
@@ -110,6 +110,9 @@ func (g *Gemini) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if g.tokenUsage.Model == "" && response.ModelVersion != "" {
|
||||
g.tokenUsage.Model = response.ModelVersion
|
||||
}
|
||||
if response.UsageMetadata != nil {
|
||||
g.tokenUsage.PromptTokens += int(response.UsageMetadata.PromptTokenCount)
|
||||
g.tokenUsage.CompletionTokens += int(response.UsageMetadata.CandidatesTokenCount)
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ type LLM interface {
|
||||
|
||||
type llm struct {
|
||||
ApiKey *model.ApiKey
|
||||
Usage *model.Usage
|
||||
Usage *TokenUsage
|
||||
tools any // TODO
|
||||
Messages []any // TODO
|
||||
llm LLM
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"opencatd-open/llm"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation#latest-preview-api-releases
|
||||
@@ -86,6 +88,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
|
||||
}
|
||||
var buildurl string
|
||||
if *o.ApiKey.Endpoint != "" {
|
||||
if strings.HasSuffix(*o.ApiKey.Endpoint, "/") {
|
||||
o.ApiKey.ApiKey = utils.ToPtr(strings.TrimSuffix(*o.ApiKey.Endpoint, "/"))
|
||||
}
|
||||
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.Endpoint, formatModel(chatReq.Model), AzureApiVersion)
|
||||
} else {
|
||||
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.ResourceNmae, formatModel(chatReq.Model), AzureApiVersion)
|
||||
@@ -116,6 +121,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if o.tokenUsage.Model == "" && chatResp.Model != "" {
|
||||
o.tokenUsage.Model = chatResp.Model
|
||||
}
|
||||
o.tokenUsage.PromptTokens = chatResp.Usage.PromptTokens
|
||||
o.tokenUsage.CompletionTokens = chatResp.Usage.CompletionTokens
|
||||
o.tokenUsage.TotalTokens = chatResp.Usage.TotalTokens
|
||||
@@ -124,6 +132,7 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
|
||||
|
||||
func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
|
||||
chatReq.Stream = true
|
||||
chatReq.StreamOptions = &openai.StreamOptions{IncludeUsage: true}
|
||||
dst, err := utils.StructToMap(chatReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -197,6 +206,7 @@ func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatReque
|
||||
if err := json.Unmarshal(line, &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if streamResp.Usage != nil {
|
||||
o.tokenUsage.PromptTokens += streamResp.Usage.PromptTokens
|
||||
o.tokenUsage.CompletionTokens += streamResp.Usage.CompletionTokens
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@ package llm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"opencatd-open/internal/model"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
@@ -15,9 +16,13 @@ type StreamChatResponse openai.ChatCompletionStreamResponse
|
||||
type ChatMessage openai.ChatCompletionMessage
|
||||
|
||||
type TokenUsage struct {
|
||||
User *model.User
|
||||
TokenID int64
|
||||
Model string `json:"model"`
|
||||
Stream bool
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
ToolsTokens int `json:"total_tokens"`
|
||||
ToolsTokens int `json:"tools_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ func AuthLLM(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("user", token.User)
|
||||
c.Set("user_id", token.User.ID)
|
||||
c.Set("token_id", token.ID)
|
||||
c.Set("authed", true)
|
||||
// 可以在这里对 token 进行验证并检查权限
|
||||
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
{
|
||||
// v1.POST("/v2/*proxypath", router.HandleProxy)
|
||||
v1.POST("/*proxypath", proxy.HandleProxy)
|
||||
// v1.GET("/models", dashboard.HandleModels)
|
||||
v1.GET("/models", proxy.HandleModels)
|
||||
}
|
||||
|
||||
idxFS, err := fs.Sub(web, "dist")
|
||||
|
||||
Reference in New Issue
Block a user