Add dedicated audio channel support (#11)
This commit is contained in:
1 parent
f7a1d95ec4
commit
1a90c280e0
20 files changed
+303
-37
No files matched your search
@@ -103,6 +103,15 @@ async function responseInterceptor(response: Response): Promise<any> {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
if (contentType && contentType.startsWith('audio/')) {
|
||||
const blob = await response.blob()
|
||||
return {
|
||||
object: 'audio',
|
||||
contentType,
|
||||
size: blob.size,
|
||||
}
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ const requestTemplates: Record<string, any> = {
|
||||
model: 'gpt-4o-mini',
|
||||
input: 'Hello, Responses API!',
|
||||
},
|
||||
'/v1/audio/speech': {
|
||||
model: 'gpt-4o-mini-tts',
|
||||
input: 'The quick brown fox jumped over the lazy dog',
|
||||
voice: 'alloy',
|
||||
},
|
||||
'/v1/completions': {
|
||||
model: 'gpt-3.5-turbo-instruct',
|
||||
prompt: 'Once upon a time',
|
||||
@@ -137,6 +142,7 @@ export function ApiTest() {
|
||||
<option value="/v1/chat/completions">/v1/chat/completions</option>
|
||||
<option value="/v1/messages">/v1/messages</option>
|
||||
<option value="/v1/responses">/v1/responses</option>
|
||||
<option value="/v1/audio/speech">/v1/audio/speech</option>
|
||||
<option value="/v1/completions">/v1/completions</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ type EditMode = 'form' | 'json'
|
||||
const channelTypes = [
|
||||
{ value: 'azure-openai', label: 'Azure OpenAI' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'azure-openai-audio', label: 'Azure OpenAI Audio' },
|
||||
{ value: 'openai-audio', label: 'OpenAI Audio' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'claude-to-openai', label: 'Claude → OpenAI' },
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses' },
|
||||
@@ -49,10 +51,12 @@ export function Channels() {
|
||||
endpoint: '',
|
||||
api_key: '',
|
||||
api_version: '',
|
||||
supported_models: [],
|
||||
deployment_mapper: {},
|
||||
})
|
||||
const [channelKey, setChannelKey] = useState('')
|
||||
const [jsonValue, setJsonValue] = useState('')
|
||||
const [supportedModelRows, setSupportedModelRows] = useState<Array<{ model: string }>>([])
|
||||
const [mapperRows, setMapperRows] = useState<Array<{ request: string; deployment: string }>>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||||
@@ -109,10 +113,12 @@ export function Channels() {
|
||||
endpoint: '',
|
||||
api_key: '',
|
||||
api_version: '',
|
||||
supported_models: [],
|
||||
deployment_mapper: {},
|
||||
})
|
||||
setChannelKey('')
|
||||
setJsonValue('')
|
||||
setSupportedModelRows([])
|
||||
setMapperRows([])
|
||||
setEditingKey(null)
|
||||
setEditMode('form')
|
||||
@@ -129,6 +135,7 @@ export function Channels() {
|
||||
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
|
||||
setFormData(config)
|
||||
setJsonValue(JSON.stringify(config, null, 2))
|
||||
setSupportedModelRows((config.supported_models || []).map((model) => ({ model })))
|
||||
|
||||
if (config.deployment_mapper) {
|
||||
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
|
||||
@@ -166,7 +173,16 @@ export function Channels() {
|
||||
}
|
||||
})
|
||||
|
||||
config = { ...formData, deployment_mapper }
|
||||
const supported_models = supportedModelRows
|
||||
.map((row) => row.model.trim())
|
||||
.filter((model) => model.length > 0)
|
||||
|
||||
if (supported_models.length === 0) {
|
||||
addToast('请至少填写一个支持模型', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
config = { ...formData, supported_models, deployment_mapper }
|
||||
if (!formData.api_version) {
|
||||
delete config.api_version
|
||||
}
|
||||
@@ -190,13 +206,17 @@ export function Channels() {
|
||||
deployment_mapper[row.request] = row.deployment
|
||||
}
|
||||
})
|
||||
const config = { ...formData, deployment_mapper }
|
||||
const supported_models = supportedModelRows
|
||||
.map((row) => row.model.trim())
|
||||
.filter((model) => model.length > 0)
|
||||
const config = { ...formData, supported_models, deployment_mapper }
|
||||
setJsonValue(JSON.stringify(config, null, 2))
|
||||
setEditMode('json')
|
||||
} else {
|
||||
try {
|
||||
const config = JSON.parse(jsonValue)
|
||||
setFormData(config)
|
||||
setSupportedModelRows((config.supported_models || []).map((model: string) => ({ model })))
|
||||
if (config.deployment_mapper) {
|
||||
const rows = Object.entries(config.deployment_mapper).map(([request, deployment]) => ({
|
||||
request,
|
||||
@@ -215,6 +235,20 @@ export function Channels() {
|
||||
setMapperRows([...mapperRows, { request: '', deployment: '' }])
|
||||
}
|
||||
|
||||
const addSupportedModelRow = () => {
|
||||
setSupportedModelRows([...supportedModelRows, { model: '' }])
|
||||
}
|
||||
|
||||
const removeSupportedModelRow = (index: number) => {
|
||||
setSupportedModelRows(supportedModelRows.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateSupportedModelRow = (index: number, value: string) => {
|
||||
const newRows = [...supportedModelRows]
|
||||
newRows[index].model = value
|
||||
setSupportedModelRows(newRows)
|
||||
}
|
||||
|
||||
const removeMapperRow = (index: number) => {
|
||||
setMapperRows(mapperRows.filter((_, i) => i !== index))
|
||||
}
|
||||
@@ -299,7 +333,7 @@ export function Channels() {
|
||||
<div className="divide-y">
|
||||
{filteredData?.map((channel) => {
|
||||
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value
|
||||
const modelCount = Object.keys(config.deployment_mapper || {}).length
|
||||
const modelCount = (config.supported_models || []).length
|
||||
const isMenuOpen = openMenu === channel.key
|
||||
|
||||
return (
|
||||
@@ -490,7 +524,7 @@ export function Channels() {
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
{(formData.type === 'azure-openai' || formData.type === 'claude' || formData.type === 'azure-openai-responses') && (
|
||||
{(formData.type === 'azure-openai' || formData.type === 'azure-openai-audio' || formData.type === 'claude' || formData.type === 'azure-openai-responses') && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">API 版本</Label>
|
||||
<Input
|
||||
@@ -505,6 +539,57 @@ export function Channels() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
支持模型
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">用于声明这个频道接受哪些请求模型名,可填写精确值或通配符</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSupportedModelRow}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{supportedModelRows.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSupportedModelRow}
|
||||
className="w-full py-8 border-2 border-dashed rounded-xl text-sm text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
添加支持模型
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{supportedModelRows.map((row, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(e) => updateSupportedModelRow(index, e.target.value)}
|
||||
placeholder="gpt-4o-mini-tts 或 gpt-*"
|
||||
className="flex-1 bg-background text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-destructive hover:text-destructive flex-shrink-0"
|
||||
onClick={() => removeSupportedModelRow(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Mappings */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
@@ -514,7 +599,7 @@ export function Channels() {
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
模型映射
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">将请求模型名映射到实际部署名</p>
|
||||
<p className="text-sm text-muted-foreground">可选。将请求模型名映射到实际上游 deployment;未填写时默认使用同名</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMapperRow}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Pricing() {
|
||||
const [editMode, setEditMode] = useState<EditMode>('table')
|
||||
const [jsonValue, setJsonValue] = useState('')
|
||||
const [pricingRows, setPricingRows] = useState<
|
||||
Array<{ model: string; input: number; output: number; cache: number }>
|
||||
Array<{ model: string; input: number; output: number; cache: number; request: number }>
|
||||
>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
@@ -49,6 +49,7 @@ export function Pricing() {
|
||||
input: pricing.input,
|
||||
output: pricing.output,
|
||||
cache: pricing.cache || 0,
|
||||
request: pricing.request || 0,
|
||||
}))
|
||||
setPricingRows(rows)
|
||||
setJsonValue(JSON.stringify(data, null, 2))
|
||||
@@ -84,6 +85,7 @@ export function Pricing() {
|
||||
input: row.input || 0,
|
||||
output: row.output || 0,
|
||||
cache: row.cache || 0,
|
||||
request: row.request || 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -108,6 +110,7 @@ export function Pricing() {
|
||||
input: row.input || 0,
|
||||
output: row.output || 0,
|
||||
cache: row.cache || 0,
|
||||
request: row.request || 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -121,6 +124,7 @@ export function Pricing() {
|
||||
input: (pricing as { input: number }).input || 0,
|
||||
output: (pricing as { output: number }).output || 0,
|
||||
cache: (pricing as { cache?: number }).cache || 0,
|
||||
request: (pricing as { request?: number }).request || 0,
|
||||
}))
|
||||
setPricingRows(rows)
|
||||
setEditMode('table')
|
||||
@@ -131,14 +135,14 @@ export function Pricing() {
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
setPricingRows([...pricingRows, { model: '', input: 0, output: 0, cache: 0 }])
|
||||
setPricingRows([...pricingRows, { model: '', input: 0, output: 0, cache: 0, request: 0 }])
|
||||
}
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
setPricingRows(pricingRows.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateRow = (index: number, field: 'model' | 'input' | 'output' | 'cache', value: string | number) => {
|
||||
const updateRow = (index: number, field: 'model' | 'input' | 'output' | 'cache' | 'request', value: string | number) => {
|
||||
const newRows = [...pricingRows]
|
||||
;(newRows[index] as any)[field] = value
|
||||
setPricingRows(newRows)
|
||||
@@ -173,7 +177,7 @@ export function Pricing() {
|
||||
<Info className="h-4 w-4 mt-0.5 flex-shrink-0 text-primary" />
|
||||
<span>
|
||||
基础配额单位:<span className="font-mono text-foreground">1M tokens = $1.00</span>。
|
||||
倍率表示相对成本,如输入倍率 30 = 每百万 token 消耗 $30 配额。
|
||||
倍率表示相对成本,如输入倍率 30 = 每百万 token 消耗 $30 配额。`按次` 为固定扣费,同样使用该配额单位,例如 15000 = $0.015 / 次。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -215,18 +219,19 @@ export function Pricing() {
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 40px' }}>
|
||||
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
|
||||
<span className="text-xs font-medium text-muted-foreground">模型名称</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">输入倍率</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">输出倍率</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">缓存倍率</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">按次</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y">
|
||||
{filteredRows.map((row) => (
|
||||
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 40px' }}>
|
||||
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(e) => updateRow(row._i, 'model', e.target.value)}
|
||||
@@ -257,6 +262,14 @@ export function Pricing() {
|
||||
min="0"
|
||||
className="font-mono text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.request}
|
||||
onChange={(e) => updateRow(row._i, 'request', parseFloat(e.target.value) || 0)}
|
||||
step="0.001"
|
||||
min="0"
|
||||
className="font-mono text-sm h-8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,11 +6,12 @@ export interface Channel {
|
||||
|
||||
export interface ChannelConfig {
|
||||
name: string
|
||||
type: 'openai' | 'azure-openai' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses'
|
||||
type: 'openai' | 'azure-openai' | 'openai-audio' | 'azure-openai-audio' | 'claude' | 'claude-to-openai' | 'openai-responses' | 'azure-openai-responses'
|
||||
endpoint: string
|
||||
api_key: string
|
||||
api_version?: string
|
||||
deployment_mapper?: Record<string, string>
|
||||
supported_models: string[]
|
||||
deployment_mapper: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
@@ -29,6 +30,7 @@ export interface PricingModel {
|
||||
input: number
|
||||
output: number
|
||||
cache?: number
|
||||
request?: number
|
||||
}
|
||||
|
||||
export type PricingConfig = Record<string, PricingModel>
|
||||
|
||||
@@ -50,10 +50,13 @@ export class ChannelUpsertEndpoint extends OpenAPIRoute {
|
||||
endpoint: z.string().describe('API endpoint'),
|
||||
api_key: z.string().describe('API key'),
|
||||
api_version: z.string().optional().describe('API version'),
|
||||
supported_models: z.array(z.string()).describe('Supported request model list'),
|
||||
deployment_mapper: z.record(z.string()).describe('Model deployment mapping'),
|
||||
model_pricing: z.record(z.object({
|
||||
input: z.number().describe('Input token price per 1000 tokens'),
|
||||
output: z.number().describe('Output token price per 1000 tokens'),
|
||||
cache: z.number().optional().describe('Cache token price per 1000 tokens'),
|
||||
request: z.number().optional().describe('Fixed request price'),
|
||||
})).optional().describe('Custom model pricing for this channel'),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@ export class PricingGetEndpoint extends OpenAPIRoute {
|
||||
...CommonSuccessfulResponse(z.record(z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache: z.number().optional(),
|
||||
request: z.number().optional(),
|
||||
}))),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
@@ -44,6 +46,8 @@ export class PricingUpdateEndpoint extends OpenAPIRoute {
|
||||
schema: z.record(z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache: z.number().optional(),
|
||||
request: z.number().optional(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,18 @@ export const TokenUtils = {
|
||||
|
||||
const pricing = await this.getPricing(c, model, targetChannelConfig);
|
||||
const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null;
|
||||
const requestCost = pricing?.request || 0
|
||||
|
||||
if (pricing && hasTokens) {
|
||||
const inputCost = usage.prompt_tokens! * pricing.input;
|
||||
const outputCost = usage.completion_tokens! * pricing.output;
|
||||
if (pricing && (hasTokens || requestCost > 0)) {
|
||||
const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0;
|
||||
const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0;
|
||||
|
||||
let cacheCost = 0;
|
||||
if (usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) {
|
||||
if (hasTokens && usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) {
|
||||
cacheCost = usage.cached_tokens * pricing.cache;
|
||||
}
|
||||
|
||||
const totalCost = inputCost + outputCost + cacheCost;
|
||||
const totalCost = inputCost + outputCost + cacheCost + requestCost;
|
||||
|
||||
await this.updateUsage(c, apiKey, totalCost);
|
||||
|
||||
@@ -52,7 +53,7 @@ export const TokenUtils = {
|
||||
+ '*'.repeat(apiKey.length / 3)
|
||||
+ apiKey.slice((2 * apiKey.length) / 3)
|
||||
);
|
||||
console.log(`Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, Cost: ${totalCost} (input: ${inputCost}, cache: ${cacheCost}, output: ${outputCost})`);
|
||||
console.log(`Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, Cost: ${totalCost} (request: ${requestCost}, input: ${inputCost}, cache: ${cacheCost}, output: ${outputCost})`);
|
||||
} else {
|
||||
console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: "v0.0.1",
|
||||
DB_VERSION_KEY: "db_version",
|
||||
DB_VERSION: "v0.0.1",
|
||||
DB_VERSION: "v0.0.2",
|
||||
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
|
||||
}
|
||||
+25
-1
@@ -43,7 +43,31 @@ const dbOperations = {
|
||||
migrate: async (c: Context<HonoCustomType>) => {
|
||||
const version = await getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
if (version != CONSTANTS.DB_VERSION) {
|
||||
// TODO: Perform migration logic here
|
||||
const channels = await c.env.DB.prepare(
|
||||
"SELECT key, value FROM channel_config"
|
||||
).all<Pick<ChannelConfigRow, "key" | "value">>();
|
||||
|
||||
for (const row of channels.results || []) {
|
||||
const config = (() => {
|
||||
try {
|
||||
return JSON.parse(row.value) as ChannelConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
config.supported_models = Object.keys(config.deployment_mapper || {});
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE channel_config
|
||||
SET value = ?, updated_at = datetime('now')
|
||||
WHERE key = ?`
|
||||
).bind(JSON.stringify(config), row.key).run();
|
||||
}
|
||||
|
||||
// Update the version in the settings table
|
||||
await saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
|
||||
|
||||
@@ -64,7 +64,7 @@ export default {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
await checkoutUsageData(saveUsage, response)
|
||||
await checkoutUsageData(saveUsage, response, requestBody)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -51,4 +51,5 @@ class UnifiedProxyEndpoint extends OpenAPIRoute {
|
||||
api.post("/v1/chat/completions", UnifiedProxyEndpoint)
|
||||
api.post("/v1/messages", UnifiedProxyEndpoint)
|
||||
api.post("/v1/responses", UnifiedProxyEndpoint)
|
||||
api.post("/v1/audio/speech", UnifiedProxyEndpoint)
|
||||
api.get("/v1/models", ModelsEndpoint)
|
||||
@@ -2,6 +2,7 @@ import { Context } from "hono";
|
||||
import { OpenAPIRoute } from "chanfana";
|
||||
import { z } from "zod";
|
||||
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./shared/auth";
|
||||
import { getSupportedModels } from "../utils";
|
||||
|
||||
export class ModelsEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
@@ -57,10 +58,8 @@ export class ModelsEndpoint extends OpenAPIRoute {
|
||||
|
||||
for (const row of channelsResult.results) {
|
||||
const config = JSON.parse(row.value) as ChannelConfig;
|
||||
if (config.deployment_mapper) {
|
||||
for (const modelName of Object.keys(config.deployment_mapper)) {
|
||||
modelsSet.add(modelName);
|
||||
}
|
||||
for (const modelName of getSupportedModels(config)) {
|
||||
modelsSet.add(modelName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
await checkoutUsageData(saveUsage, response)
|
||||
await checkoutUsageData(saveUsage, response, requestBody)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from "hono"
|
||||
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./auth"
|
||||
import { RouteId, getRoutePolicy } from "./route-policy"
|
||||
import { findDeploymentMapping } from "../../utils"
|
||||
import { findDeploymentMapping, findSupportedModel, getSupportedModels } from "../../utils"
|
||||
import { TokenUtils } from "../../admin/token_utils"
|
||||
|
||||
export type ChannelResolution = {
|
||||
@@ -63,7 +63,17 @@ export const resolveChannel = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapping = findDeploymentMapping(config.deployment_mapper, model);
|
||||
const supportedPattern = findSupportedModel(getSupportedModels(config), model)
|
||||
|
||||
if (!supportedPattern) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapping = findDeploymentMapping(config.deployment_mapper, model) || {
|
||||
pattern: supportedPattern,
|
||||
deployment: model,
|
||||
};
|
||||
|
||||
if (mapping) {
|
||||
availableChannels.push({
|
||||
key: row.key,
|
||||
@@ -74,7 +84,7 @@ export const resolveChannel = async (
|
||||
}
|
||||
|
||||
if (availableChannels.length === 0) {
|
||||
return c.text(`Model not mapped: ${model}. Please configure deployment_mapper.`, 400);
|
||||
return c.text(`Model not supported: ${model}. Please configure supported_models or deployment_mapper.`, 400);
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * availableChannels.length);
|
||||
|
||||
@@ -1,13 +1,78 @@
|
||||
import { Context } from "hono"
|
||||
|
||||
const AUDIO_OUTPUT_TOKENS_PER_MINUTE = 1250
|
||||
const TEXT_TOKENS_PER_CHAR = 0.25
|
||||
const SPEECH_CHARS_PER_MINUTE = 750
|
||||
|
||||
const collectInputText = (input: unknown): string => {
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((item) => collectInputText(item))
|
||||
.filter((text) => text.length > 0)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
if ("text" in input && typeof input.text === "string") {
|
||||
return input.text
|
||||
}
|
||||
|
||||
if ("content" in input) {
|
||||
return collectInputText(input.content)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const estimateAudioSpeechUsage = (requestBody: any): Usage | null => {
|
||||
const inputText = collectInputText(requestBody?.input).trim()
|
||||
if (!inputText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const characterCount = Array.from(inputText).length
|
||||
const promptTokens = Math.max(1, Math.ceil(characterCount * TEXT_TOKENS_PER_CHAR))
|
||||
const estimatedMinutes = Math.max(1 / 60, characterCount / SPEECH_CHARS_PER_MINUTE)
|
||||
const completionTokens = Math.max(1, Math.ceil(estimatedMinutes * AUDIO_OUTPUT_TOKENS_PER_MINUTE))
|
||||
|
||||
return {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
}
|
||||
}
|
||||
|
||||
export const checkoutUsageData = async (
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
response: Response | OpenAIResponse
|
||||
response: Response | OpenAIResponse,
|
||||
requestBody?: any,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const res = response instanceof Response
|
||||
? await response.clone().json<OpenAIResponse>()
|
||||
: response
|
||||
if (response instanceof Response) {
|
||||
const contentType = response.headers.get("content-type") || ""
|
||||
if (contentType.startsWith("audio/")) {
|
||||
const estimatedUsage = estimateAudioSpeechUsage(requestBody)
|
||||
if (!estimatedUsage) return
|
||||
await saveUsage(estimatedUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!contentType.includes("application/json")) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await response.clone().json<OpenAIResponse>()
|
||||
if (!res.usage) return;
|
||||
await saveUsage(res.usage)
|
||||
return
|
||||
}
|
||||
|
||||
const res = response
|
||||
if (!res.usage) return;
|
||||
await saveUsage(res.usage)
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,8 @@ type ProviderFetch = (
|
||||
const providerMap: Record<string, ProviderFetch> = {
|
||||
"azure-openai": azureOpenaiProxy.fetch,
|
||||
"openai": openaiProxy.fetch,
|
||||
"azure-openai-audio": azureOpenaiProxy.fetch,
|
||||
"openai-audio": openaiProxy.fetch,
|
||||
"claude": claudeProxy.fetch,
|
||||
"claude-to-openai": claudeToOpenaiProxy.fetch,
|
||||
"openai-responses": openaiResponsesProxy.fetch,
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
export type RouteId = "chat-completions" | "messages" | "responses"
|
||||
export type RouteId = "chat-completions" | "messages" | "responses" | "audio-speech"
|
||||
|
||||
type RoutePolicy = {
|
||||
allowedTypes: ChannelType[] | null
|
||||
}
|
||||
|
||||
const TEXT_CHANNEL_TYPES: ChannelType[] = [
|
||||
"openai",
|
||||
"azure-openai",
|
||||
"claude",
|
||||
"claude-to-openai",
|
||||
]
|
||||
|
||||
const ROUTE_POLICIES: Record<RouteId, RoutePolicy> = {
|
||||
"chat-completions": { allowedTypes: null },
|
||||
"messages": { allowedTypes: null },
|
||||
"chat-completions": { allowedTypes: TEXT_CHANNEL_TYPES },
|
||||
"messages": { allowedTypes: TEXT_CHANNEL_TYPES },
|
||||
"responses": { allowedTypes: ["openai-responses", "azure-openai-responses"] },
|
||||
"audio-speech": { allowedTypes: ["openai-audio", "azure-openai-audio"] },
|
||||
}
|
||||
|
||||
export const resolveRouteId = (pathname: string): RouteId | null => {
|
||||
if (pathname.endsWith("/chat/completions")) return "chat-completions"
|
||||
if (pathname.endsWith("/messages")) return "messages"
|
||||
if (pathname.endsWith("/responses")) return "responses"
|
||||
if (pathname.endsWith("/audio/speech")) return "audio-speech"
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -106,3 +106,32 @@ export const findDeploymentMapping = (
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const findSupportedModel = (
|
||||
supportedModels: string[] | undefined,
|
||||
model: string
|
||||
): string | null => {
|
||||
if (!supportedModels || supportedModels.length === 0) return null;
|
||||
|
||||
if (supportedModels.includes(model)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
for (const pattern of supportedModels) {
|
||||
if (pattern.includes('*') && wildcardMatch(pattern, model)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getSupportedModels = (
|
||||
config: Pick<ChannelConfig, "supported_models" | "deployment_mapper">
|
||||
): string[] => {
|
||||
if (config.supported_models && config.supported_models.length > 0) {
|
||||
return config.supported_models;
|
||||
}
|
||||
|
||||
return Object.keys(config.deployment_mapper || {});
|
||||
}
|
||||
@@ -35,6 +35,8 @@ type ApiTokenRow = BaseDbRow & {
|
||||
type ChannelType =
|
||||
| "azure-openai"
|
||||
| "openai"
|
||||
| "azure-openai-audio"
|
||||
| "openai-audio"
|
||||
| "claude"
|
||||
| "claude-to-openai"
|
||||
| "openai-responses"
|
||||
@@ -48,6 +50,7 @@ type ChannelConfig = {
|
||||
endpoint: string;
|
||||
api_key: string;
|
||||
api_version?: string;
|
||||
supported_models?: string[];
|
||||
deployment_mapper: Record<string, string>;
|
||||
model_pricing?: Record<string, ModelPricing>;
|
||||
}
|
||||
@@ -82,6 +85,7 @@ type ModelPricing = {
|
||||
input: number;
|
||||
output: number;
|
||||
cache?: number;
|
||||
request?: number;
|
||||
}
|
||||
|
||||
type ApiTokenData = {
|
||||
|
||||
Reference in new issue
Block a user