mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 00:33:13 +00:00
Merge remote-tracking branch 'origin/main' into feat/upstream-rate-scheduling
# Conflicts: # backend/internal/handler/openai_gateway_handler.go
This commit is contained in:
commit
0408bdb34f
103 files changed
+6095
-347
No files matched your search
@@ -30,6 +30,7 @@ export interface ChannelModelPricing {
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: PricingInterval[]
|
||||
@@ -154,6 +155,7 @@ export interface ModelDefaultPricing {
|
||||
output_price?: number
|
||||
cache_write_price?: number
|
||||
cache_read_price?: number
|
||||
image_input_price?: number
|
||||
image_output_price?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface UserSupportedModelPricing {
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: UserPricingInterval[]
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
{{ t('admin.channels.form.defaultPrices') }}
|
||||
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }}</label>
|
||||
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
@@ -122,6 +122,11 @@
|
||||
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageInputPrice') }}</label>
|
||||
<input :value="entry.image_input_price" @input="emitField('image_input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
@@ -328,6 +333,7 @@ async function onModelsUpdate(newModels: string[]) {
|
||||
output_price: perTokenToMTok(result.output_price ?? null),
|
||||
cache_write_price: perTokenToMTok(result.cache_write_price ?? null),
|
||||
cache_read_price: perTokenToMTok(result.cache_read_price ?? null),
|
||||
image_input_price: perTokenToMTok(result.image_input_price ?? null),
|
||||
image_output_price: perTokenToMTok(result.image_output_price ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PricingFormEntry {
|
||||
output_price: number | string | null
|
||||
cache_write_price: number | string | null
|
||||
cache_read_price: number | string | null
|
||||
image_input_price: number | string | null
|
||||
image_output_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
intervals: IntervalFormEntry[]
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasImageInputTokens(row)" class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-fuchsia-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span class="font-medium text-fuchsia-600 dark:text-fuchsia-400">{{ row.image_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
@@ -246,10 +252,18 @@
|
||||
<div class="space-y-1.5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0 && !hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData) && textInputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ textInputTokens(tokenTooltipData).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokens') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ tokenTooltipData.image_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
@@ -327,6 +341,10 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputCost(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputCost') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
@@ -337,9 +355,13 @@
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
|
||||
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<div v-if="tooltipData && textInputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, textInputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputTokens(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokenPrice') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ formatTokenPricePerMillion(tooltipData.image_input_cost ?? 0, tooltipData.image_input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
|
||||
@@ -471,6 +493,9 @@ import {
|
||||
hasImageOutputTokens,
|
||||
textOutputTokens,
|
||||
hasImageOutputCost,
|
||||
hasImageInputTokens,
|
||||
textInputTokens,
|
||||
hasImageInputCost,
|
||||
} from '@/utils/imageUsage'
|
||||
|
||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||
|
||||
@@ -90,6 +90,13 @@
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
v-if="model.pricing.image_input_price != null && model.pricing.image_input_price > 0"
|
||||
:label="t(prefixKey('imageInputPrice'))"
|
||||
:value="model.pricing.image_input_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
v-if="model.pricing.image_output_price != null && model.pricing.image_output_price > 0"
|
||||
:label="t(prefixKey('imageOutputPrice'))"
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
</p>
|
||||
|
||||
<!-- Client Tabs -->
|
||||
<div v-if="clientTabs.length" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Client">
|
||||
<div v-if="clientTabs.length" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex min-w-max gap-4 sm:gap-6" aria-label="Client">
|
||||
<button
|
||||
v-for="tab in clientTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeClientTab = tab.id"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
@@ -102,11 +103,12 @@
|
||||
</div>
|
||||
|
||||
<!-- OS/Shell Tabs -->
|
||||
<div v-if="showShellTabs" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
|
||||
<div v-if="showShellTabs" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex min-w-max gap-4" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in currentTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
@@ -138,10 +140,11 @@
|
||||
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
|
||||
<!-- Code Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-dark-800 border-b border-gray-700 dark:border-dark-700">
|
||||
<span class="text-xs text-gray-400 font-mono">{{ file.path }}</span>
|
||||
<span class="min-w-0 truncate text-xs text-gray-400 font-mono">{{ file.path }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="copyContent(file.content, index)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
|
||||
class="flex flex-shrink-0 items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
|
||||
:class="copiedIndex === index
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'"
|
||||
@@ -353,6 +356,8 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
case 'grok':
|
||||
return [
|
||||
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
@@ -403,6 +408,12 @@ const platformDescription = computed(() => {
|
||||
case 'antigravity':
|
||||
return t('keys.useKeyModal.antigravity.description')
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.grok.claudeDescription')
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return t('keys.useKeyModal.grok.codexDescription')
|
||||
}
|
||||
return t('keys.useKeyModal.grok.description')
|
||||
default:
|
||||
return t('keys.useKeyModal.description')
|
||||
@@ -425,6 +436,14 @@ const platformNote = computed(() => {
|
||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.grok.claudeNote')
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.codexNoteWindows')
|
||||
: t('keys.useKeyModal.grok.codexNote')
|
||||
}
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.noteWindows')
|
||||
: t('keys.useKeyModal.grok.note')
|
||||
@@ -509,6 +528,12 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
}
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return generateGrokClaudeFiles(baseRoot, apiKey)
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return generateGrokCodexFiles(apiBase, apiKey)
|
||||
}
|
||||
return generateGrokFiles(apiBase, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
@@ -548,9 +573,10 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
|
||||
|
||||
const vscodeSettingsPath = activeTab.value === 'unix'
|
||||
? '~/.claude/settings.json'
|
||||
: '%userprofile%\\.claude\\settings.json'
|
||||
: '%USERPROFILE%\\.claude\\settings.json'
|
||||
|
||||
const vscodeContent = `{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "${baseUrl}",
|
||||
"ANTHROPIC_AUTH_TOKEN": "${apiKey}",
|
||||
@@ -561,7 +587,68 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
|
||||
|
||||
return [
|
||||
{ path, content },
|
||||
{ path: vscodeSettingsPath, content: vscodeContent, hint: 'VSCode Claude Code' }
|
||||
{
|
||||
path: vscodeSettingsPath,
|
||||
content: vscodeContent,
|
||||
hint: t('keys.useKeyModal.claudeSettingsHint')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function generateGrokClaudeFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const environment = {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'grok-4.5',
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: 'grok-4.5',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
|
||||
}
|
||||
let path: string
|
||||
let content: string
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
path = 'Terminal'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `export ${name}="${value}"`)
|
||||
.join('\n')
|
||||
break
|
||||
case 'cmd':
|
||||
path = 'Command Prompt'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `set ${name}=${value}`)
|
||||
.join('\n')
|
||||
break
|
||||
case 'powershell':
|
||||
path = 'PowerShell'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `$env:${name}="${value}"`)
|
||||
.join('\n')
|
||||
break
|
||||
default:
|
||||
path = 'Terminal'
|
||||
content = ''
|
||||
}
|
||||
|
||||
const settingsPath = activeTab.value === 'unix'
|
||||
? '~/.claude/settings.json'
|
||||
: '%USERPROFILE%\\.claude\\settings.json'
|
||||
|
||||
return [
|
||||
{ path, content },
|
||||
{
|
||||
path: settingsPath,
|
||||
content: JSON.stringify({
|
||||
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
||||
env: environment
|
||||
}, null, 2),
|
||||
hint: t('keys.useKeyModal.claudeSettingsHint')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -682,6 +769,39 @@ supports_backend_search = true`
|
||||
}]
|
||||
}
|
||||
|
||||
function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configPath = isWindows
|
||||
? '%USERPROFILE%\\.codex\\config.toml'
|
||||
: '~/.codex/config.toml'
|
||||
const configContent = `model_provider = "sub2api_grok"
|
||||
model = "grok-4.5"
|
||||
review_model = "grok-4.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
model_context_window = 1000000
|
||||
|
||||
[model_providers.sub2api_grok]
|
||||
name = "Sub2API Grok"
|
||||
base_url = "${baseUrl}"
|
||||
env_key = "SUB2API_API_KEY"
|
||||
wire_api = "responses"`
|
||||
const environmentContent = isWindows
|
||||
? `$env:SUB2API_API_KEY="${apiKey}"`
|
||||
: `export SUB2API_API_KEY="${apiKey}"`
|
||||
|
||||
return [
|
||||
{
|
||||
path: configPath,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.grok.codexConfigTomlHint')
|
||||
},
|
||||
{
|
||||
path: isWindows ? 'PowerShell' : 'Terminal',
|
||||
content: environmentContent
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({
|
||||
copyToClipboardMock: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
@@ -10,7 +14,7 @@ vi.mock('vue-i18n', () => ({
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true)
|
||||
copyToClipboard: copyToClipboardMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -78,6 +82,153 @@ describe('UseKeyModal', () => {
|
||||
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders copyable Claude Code setup through the Grok Messages gateway', async () => {
|
||||
copyToClipboardMock.mockClear()
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-claude-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const claudeTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.claudeCode')
|
||||
)
|
||||
expect(claudeTab).toBeDefined()
|
||||
await claudeTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_BASE_URL="https://example.com"')
|
||||
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"')
|
||||
const unixConfig = codeBlocks.find((content) => content.startsWith('export ANTHROPIC_BASE_URL'))
|
||||
expect(unixConfig).toBeDefined()
|
||||
for (const name of [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_FABLE_MODEL',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL'
|
||||
]) {
|
||||
expect(unixConfig).toContain(`export ${name}="grok-4.5"`)
|
||||
}
|
||||
const settingsConfig = codeBlocks.find((content) => content.includes('"$schema"'))
|
||||
expect(settingsConfig).toBeDefined()
|
||||
const parsedSettings = JSON.parse(settingsConfig!)
|
||||
expect(parsedSettings.$schema).toBe('https://json.schemastore.org/claude-code-settings.json')
|
||||
expect(parsedSettings.env.ANTHROPIC_MODEL).toBe('grok-4.5')
|
||||
expect(wrapper.text()).toContain('keys.useKeyModal.claudeSettingsHint')
|
||||
expect(wrapper.text()).toContain('keys.useKeyModal.grok.claudeNote')
|
||||
expect(wrapper.find('nav[aria-label="Client"]').classes()).toContain('min-w-max')
|
||||
expect(wrapper.find('nav[aria-label="Client"]').element.parentElement?.classList.contains('overflow-x-auto')).toBe(true)
|
||||
|
||||
const cmdTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows CMD'
|
||||
)
|
||||
expect(cmdTab).toBeDefined()
|
||||
await cmdTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_MODEL=grok-4.5')
|
||||
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_DEFAULT_FABLE_MODEL=grok-4.5')
|
||||
expect(codeBlocks.join('\n')).toContain('set CLAUDE_CODE_SUBAGENT_MODEL=grok-4.5')
|
||||
|
||||
const powershellTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'PowerShell'
|
||||
)
|
||||
expect(powershellTab).toBeDefined()
|
||||
await powershellTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_BASE_URL="https://example.com"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_MODEL="grok-4.5"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_DEFAULT_FABLE_MODEL="grok-4.5"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:CLAUDE_CODE_SUBAGENT_MODEL="grok-4.5"')
|
||||
expect(wrapper.text()).toContain('%USERPROFILE%\\.claude\\settings.json')
|
||||
|
||||
const copyButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.copy')
|
||||
)
|
||||
expect(copyButton).toBeDefined()
|
||||
await copyButton!.trigger('click')
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"'),
|
||||
'keys.copied'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders Codex custom provider setup through the Grok Responses gateway', async () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-codex-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const codexTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
|
||||
)
|
||||
expect(codexTab).toBeDefined()
|
||||
await codexTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api_grok]'))
|
||||
expect(configToml).toBeDefined()
|
||||
expect(configToml).toContain('model_provider = "sub2api_grok"')
|
||||
expect(configToml).toContain('model = "grok-4.5"')
|
||||
expect(configToml).toContain('model_context_window = 1000000')
|
||||
expect(configToml).toContain('base_url = "https://example.com/v1"')
|
||||
expect(configToml).toContain('env_key = "SUB2API_API_KEY"')
|
||||
expect(configToml).toContain('wire_api = "responses"')
|
||||
expect(configToml).not.toContain('requires_openai_auth')
|
||||
expect(configToml).not.toContain('disable_response_storage')
|
||||
expect(configToml).not.toContain('network_access')
|
||||
expect(configToml).not.toContain('windows_wsl_setup_acknowledged')
|
||||
expect(configToml).not.toContain('[features]')
|
||||
expect(configToml).not.toContain('goals = true')
|
||||
expect(codeBlocks).toContain('export SUB2API_API_KEY="sk-grok-codex-test"')
|
||||
expect(wrapper.text()).not.toContain('auth.json')
|
||||
|
||||
const windowsTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows'
|
||||
)
|
||||
expect(windowsTab).toBeDefined()
|
||||
await windowsTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(wrapper.text()).toContain('%USERPROFILE%\\.codex\\config.toml')
|
||||
expect(codeBlocks).toContain('$env:SUB2API_API_KEY="sk-grok-codex-test"')
|
||||
})
|
||||
|
||||
it('keeps legacy OpenAI Codex config as the default', () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
|
||||
@@ -100,10 +100,6 @@ onUnmounted(() => {
|
||||
@apply flex-none min-h-fit;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
|
||||
@apply flex-none;
|
||||
display: table;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../TablePageLayout.vue')
|
||||
const componentSource = readFileSync(componentPath, 'utf8')
|
||||
|
||||
describe('TablePageLayout responsive table scrolling', () => {
|
||||
it('does not disable the table horizontal scroll container in mobile mode', () => {
|
||||
const tableWrapperBlocks = Array.from(
|
||||
componentSource.matchAll(/([^{}]*:deep\(\.table-wrapper\)[^{}]*)\{([^{}]*)\}/g)
|
||||
)
|
||||
|
||||
expect(tableWrapperBlocks.length).toBeGreaterThan(0)
|
||||
|
||||
const baseBlock = tableWrapperBlocks.find(([selector]) => !selector.includes('.mobile-mode'))
|
||||
const mobileBlocks = tableWrapperBlocks.filter(([selector]) => selector.includes('.mobile-mode'))
|
||||
|
||||
expect(baseBlock?.[2]).toContain('overflow-x-auto')
|
||||
expect(mobileBlocks.every(([, , declarations]) => !declarations.includes('overflow-visible'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,7 @@ export default {
|
||||
cacheReadPrice: 'Cache Read',
|
||||
cacheWritePriceShort: 'Cache W',
|
||||
cacheReadPriceShort: 'Cache R',
|
||||
imageInputPrice: 'Image Input',
|
||||
imageTokenPrice: 'Image Output',
|
||||
imageOutputPrice: 'Image Output Price',
|
||||
pricePlaceholder: 'Default',
|
||||
|
||||
@@ -134,6 +134,7 @@ export default {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
claudeSettingsHint: 'User-level persistent configuration. Do not commit this file containing your API key to a project repository.',
|
||||
noGroupTitle: 'Please assign a group first',
|
||||
noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.',
|
||||
openai: {
|
||||
@@ -167,10 +168,16 @@ export default {
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
grok: {
|
||||
description: 'Configure Grok Build or OpenCode to send Responses API traffic through your Sub2API Grok group.',
|
||||
description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.',
|
||||
claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.',
|
||||
codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.',
|
||||
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
|
||||
codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.',
|
||||
claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.',
|
||||
codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.',
|
||||
codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
@@ -325,6 +332,9 @@ export default {
|
||||
imageBillingSize: 'Billing size',
|
||||
imageInputSize: 'Input size',
|
||||
imageOutputSize: 'Output size',
|
||||
imageInputTokens: 'Image Input Tokens',
|
||||
imageInputTokenPrice: 'Image Input Price',
|
||||
imageInputCost: 'Image Input Cost',
|
||||
imageOutputTokens: 'Image Output Tokens',
|
||||
imageOutputTokenPrice: 'Image Output Price',
|
||||
imageOutputCost: 'Image Output Cost',
|
||||
@@ -499,6 +509,7 @@ export default {
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
cacheReadPrice: 'Cache Read',
|
||||
imageInputPrice: 'Image Input',
|
||||
imageOutputPrice: 'Image Output',
|
||||
perRequestPrice: 'Per Request',
|
||||
intervals: 'Tiered Pricing',
|
||||
|
||||
@@ -116,6 +116,7 @@ export default {
|
||||
cacheReadPrice: '缓存读取',
|
||||
cacheWritePriceShort: '缓存写',
|
||||
cacheReadPriceShort: '缓存读',
|
||||
imageInputPrice: '图片输入',
|
||||
imageTokenPrice: '图片输出',
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
|
||||
@@ -133,6 +133,7 @@ export default {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
claudeSettingsHint: '用户级持久配置。此文件包含 API 密钥,请勿提交到项目仓库。',
|
||||
noGroupTitle: '请先分配分组',
|
||||
noGroupDescription:
|
||||
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
|
||||
@@ -171,10 +172,16 @@ export default {
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。',
|
||||
claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。'
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。',
|
||||
codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。',
|
||||
codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
@@ -330,6 +337,9 @@ export default {
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageInputTokens: '图片输入 Token',
|
||||
imageInputTokenPrice: '图片输入单价',
|
||||
imageInputCost: '图片输入费用',
|
||||
imageOutputTokens: '图片输出 Token',
|
||||
imageOutputTokenPrice: '图片输出单价',
|
||||
imageOutputCost: '图片输出费用',
|
||||
@@ -504,6 +514,7 @@ export default {
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
imageInputPrice: '图片输入',
|
||||
imageOutputPrice: '图片输出',
|
||||
perRequestPrice: '每次请求',
|
||||
intervals: '阶梯定价',
|
||||
|
||||
@@ -1442,6 +1442,8 @@ export interface UsageLog {
|
||||
image_output_size: string | null
|
||||
image_size_source: ImageSizeSource | null
|
||||
image_size_breakdown: ImageSizeBreakdown | null
|
||||
image_input_tokens: number
|
||||
image_input_cost: number
|
||||
image_output_tokens: number
|
||||
image_output_cost: number
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): n
|
||||
export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
|
||||
(row?.image_output_cost ?? 0) > 0
|
||||
|
||||
// --- Image input token / cost helpers ---
|
||||
|
||||
type ImageInputTokenRow = Pick<UsageLog, 'input_tokens' | 'image_input_tokens'>
|
||||
type ImageInputCostRow = Pick<UsageLog, 'image_input_cost'>
|
||||
|
||||
/** Whether the row contains any image-input tokens (e.g. gpt-image-2 image edits). */
|
||||
export const hasImageInputTokens = (row: ImageInputTokenRow | null | undefined): boolean =>
|
||||
(row?.image_input_tokens ?? 0) > 0
|
||||
|
||||
/**
|
||||
* Text-only input tokens (total input minus image-input).
|
||||
* Returns 0 when no text tokens exist.
|
||||
*/
|
||||
export const textInputTokens = (row: ImageInputTokenRow | null | undefined): number =>
|
||||
Math.max(0, (row?.input_tokens ?? 0) - (row?.image_input_tokens ?? 0))
|
||||
|
||||
/** Whether the row has a non-zero image-input cost. */
|
||||
export const hasImageInputCost = (row: ImageInputCostRow | null | undefined): boolean =>
|
||||
(row?.image_input_cost ?? 0) > 0
|
||||
|
||||
// --- Image size / billing helpers ---
|
||||
|
||||
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
|
||||
|
||||
@@ -855,6 +855,7 @@ function addPricingEntry(sectionIdx: number) {
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
@@ -887,6 +888,7 @@ async function syncLatestModels(sectionIdx: number) {
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
@@ -951,6 +953,7 @@ function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: []
|
||||
@@ -1066,6 +1069,7 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
|
||||
output_price: mTokToPerToken(p.output_price),
|
||||
cache_write_price: mTokToPerToken(p.cache_write_price),
|
||||
cache_read_price: mTokToPerToken(p.cache_read_price),
|
||||
image_input_price: mTokToPerToken(p.image_input_price),
|
||||
image_output_price: mTokToPerToken(p.image_output_price),
|
||||
per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null,
|
||||
intervals: formIntervalsToAPI(p.intervals || [])
|
||||
@@ -1106,6 +1110,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
|
||||
output_price: mTokToPerToken(entry.output_price),
|
||||
cache_write_price: mTokToPerToken(entry.cache_write_price),
|
||||
cache_read_price: mTokToPerToken(entry.cache_read_price),
|
||||
image_input_price: mTokToPerToken(entry.image_input_price),
|
||||
image_output_price: mTokToPerToken(entry.image_output_price),
|
||||
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
|
||||
intervals: formIntervalsToAPI(entry.intervals || [])
|
||||
@@ -1194,6 +1199,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
||||
output_price: perTokenToMTok(p.output_price),
|
||||
cache_write_price: perTokenToMTok(p.cache_write_price),
|
||||
cache_read_price: perTokenToMTok(p.cache_read_price),
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
intervals: apiIntervalsToForm(p.intervals || [])
|
||||
@@ -1382,6 +1388,7 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
|
||||
output_price: perTokenToMTok(p.output_price),
|
||||
cache_write_price: perTokenToMTok(p.cache_write_price),
|
||||
cache_read_price: perTokenToMTok(p.cache_read_price),
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
intervals: apiIntervalsToForm(p.intervals || [])
|
||||
|
||||
Reference in new issue
Block a user