Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
34913ce477 fix: handle fork PRs in auto-format workflow
- Use head.sha instead of head_ref for checkout (works for forks)
- For fork PRs: fail with helpful message if formatting needed
- For same-repo PRs: auto-commit and push as before
2025-12-26 12:14:06 +09:00
26 changed files with 119 additions and 139 deletions

View File

@@ -162,7 +162,6 @@ interface ChatInputProps {
models?: FlattenedModel[] models?: FlattenedModel[]
selectedModelId?: string selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void onConfigureModels?: () => void
} }
@@ -184,7 +183,6 @@ export function ChatInput({
models = [], models = [],
selectedModelId, selectedModelId,
onModelSelect = () => {}, onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {}, onConfigureModels = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const dict = useDictionary() const dict = useDictionary()
@@ -484,7 +482,6 @@ export function ChatInput({
onSelect={onModelSelect} onSelect={onModelSelect}
onConfigure={onConfigureModels} onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />

View File

@@ -1071,7 +1071,6 @@ export default function ChatPanel({
models={modelConfig.models} models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId} selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId} onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)} onConfigureModels={() => setShowModelConfigDialog(true)}
/> />
</footer> </footer>

View File

@@ -50,7 +50,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -1448,23 +1447,10 @@ export function ModelConfigDialog({
{/* Footer */} {/* Footer */}
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0"> <div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
<div className="flex items-center justify-between"> <p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
<div className="flex items-center gap-2"> <Key className="h-3 w-3" />
<Switch {dict.modelConfig.apiKeyStored}
checked={modelConfig.showUnvalidatedModels} </p>
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored}
</p>
</div>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,13 +1,6 @@
"use client" "use client"
import { import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
AlertTriangle,
Bot,
Check,
ChevronDown,
Server,
Settings2,
} from "lucide-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { import {
ModelSelectorContent, ModelSelectorContent,
@@ -33,7 +26,6 @@ interface ModelSelectorProps {
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure: () => void onConfigure: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean
} }
// Map our provider names to models.dev logo names // Map our provider names to models.dev logo names
@@ -75,20 +67,17 @@ export function ModelSelector({
onSelect, onSelect,
onConfigure, onConfigure,
disabled = false, disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const dict = useDictionary() const dict = useDictionary()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Filter models based on showUnvalidatedModels setting // Only show validated models in the selector
const displayModels = useMemo(() => { const validatedModels = useMemo(
if (showUnvalidatedModels) { () => models.filter((m) => m.validated === true),
return models [models],
} )
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
const groupedModels = useMemo( const groupedModels = useMemo(
() => groupModelsByProvider(displayModels), () => groupModelsByProvider(validatedModels),
[displayModels], [validatedModels],
) )
// Find selected model for display // Find selected model for display
@@ -137,7 +126,7 @@ export function ModelSelector({
/> />
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"> <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty> <ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0 {validatedModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels ? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound} : dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
@@ -202,16 +191,6 @@ export function ModelSelector({
<ModelSelectorName> <ModelSelectorName>
{model.modelId} {model.modelId}
</ModelSelectorName> </ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem> </ModelSelectorItem>
))} ))}
</ModelSelectorGroup> </ModelSelectorGroup>
@@ -234,9 +213,7 @@ export function ModelSelector({
</ModelSelectorGroup> </ModelSelectorGroup>
{/* Info text */} {/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t"> <div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels {dict.modelConfig.onlyVerifiedShown}
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div> </div>
</ModelSelectorList> </ModelSelectorList>
</ModelSelectorContent> </ModelSelectorContent>

View File

@@ -33,7 +33,7 @@ services:
| Scenario | URL Value | | Scenario | URL Value |
|----------|-----------| |----------|-----------|
| Localhost | `http://localhost:8080` | | Localhost | `http://localhost:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` | | Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them. **Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -6,7 +6,7 @@
## Usage ## Usage
```xml ```xml
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1"> <mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" /> <mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell> </mxCell>
``` ```

View File

@@ -109,11 +109,9 @@ export interface UseModelConfigReturn {
models: FlattenedModel[] models: FlattenedModel[]
selectedModel: FlattenedModel | undefined selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined selectedModelId: string | undefined
showUnvalidatedModels: boolean
// Actions // Actions
setSelectedModelId: (modelId: string | undefined) => void setSelectedModelId: (modelId: string | undefined) => void
setShowUnvalidatedModels: (show: boolean) => void
addProvider: (provider: ProviderName) => ProviderConfig addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: ( updateProvider: (
providerId: string, providerId: string,
@@ -162,13 +160,6 @@ export function useModelConfig(): UseModelConfigReturn {
})) }))
}, []) }, [])
const setShowUnvalidatedModels = useCallback((show: boolean) => {
setConfig((prev) => ({
...prev,
showUnvalidatedModels: show,
}))
}, [])
const addProvider = useCallback( const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => { (provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider) const newProvider = createProviderConfig(provider)
@@ -287,9 +278,7 @@ export function useModelConfig(): UseModelConfigReturn {
models, models,
selectedModel, selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
setSelectedModelId, setSelectedModelId,
setShowUnvalidatedModels,
addProvider, addProvider,
updateProvider, updateProvider,
deleteProvider, deleteProvider,

View File

@@ -27,7 +27,6 @@ try {
/** /**
* Get today's date string in the configured timezone (YYYY-MM-DD format) * Get today's date string in the configured timezone (YYYY-MM-DD format)
* This is used as the Sort Key (SK) for per-day tracking
*/ */
function getTodayInTimezone(): string { function getTodayInTimezone(): string {
return new Intl.DateTimeFormat("en-CA", { return new Intl.DateTimeFormat("en-CA", {
@@ -62,8 +61,8 @@ interface QuotaCheckResult {
/** /**
* Check all quotas and increment request count atomically. * Check all quotas and increment request count atomically.
* Uses composite key (PK=user, SK=date) for per-day tracking. * Uses ConditionExpression to prevent race conditions.
* Each day automatically gets a new item - no explicit reset needed. * Returns which limit was exceeded if any.
*/ */
export async function checkAndIncrementRequest( export async function checkAndIncrementRequest(
ip: string, ip: string,
@@ -74,33 +73,77 @@ export async function checkAndIncrementRequest(
return { allowed: true } return { allowed: true }
} }
const pk = ip // User identifier (base64 IP) const today = getTodayInTimezone()
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString() const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try { try {
// Single atomic update - handles creation AND increment // First, try to reset counts if it's a new day (atomic day reset)
// New day automatically creates new item (different SK) // This will succeed only if lastResetDate < today or doesn't exist
// Note: lastMinute/tpmCount are managed by recordTokenUsage only try {
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } },
// Reset all counts to 1/0 for the new day
UpdateExpression: `
SET lastResetDate = :today,
dailyReqCount = :one,
dailyTokenCount = :zero,
lastMinute = :minute,
tpmCount = :zero,
#ttl = :ttl
`,
// Only succeed if it's a new day (or new item)
ConditionExpression: `
attribute_not_exists(lastResetDate) OR lastResetDate < :today
`,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" },
":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
},
}),
)
// New day reset successful
return { allowed: true }
} catch (resetError: any) {
// If condition failed, it's the same day - continue to increment logic
if (!(resetError instanceof ConditionalCheckFailedException)) {
throw resetError // Re-throw unexpected errors
}
}
// Same day - increment request count with limit checks
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { Key: { PK: { S: `IP#${ip}` } },
PK: { S: pk }, // Increment request count, handle minute boundary for TPM
SK: { S: sk }, UpdateExpression: `
}, SET lastMinute = :minute,
UpdateExpression: "ADD reqCount :one", tpmCount = if_not_exists(tpmCount, :zero),
#ttl = :ttl
ADD dailyReqCount :one
`,
// Check all limits before allowing increment // Check all limits before allowing increment
// TPM check: allow if new minute OR under limit
ConditionExpression: ` ConditionExpression: `
(attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND lastResetDate = :today AND
(attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND (attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR (attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`, `,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: { ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" }, ":one": { N: "1" },
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
":reqLimit": { N: String(limits.requests || 999999) }, ":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) }, ":tpmLimit": { N: String(limits.tpm || 999999) },
@@ -117,39 +160,42 @@ export async function checkAndIncrementRequest(
const getResult = await client.send( const getResult = await client.send(
new GetItemCommand({ new GetItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { Key: { PK: { S: `IP#${ip}` } },
PK: { S: pk },
SK: { S: sk },
},
}), }),
) )
const item = getResult.Item const item = getResult.Item
const storedDate = item?.lastResetDate?.S
const storedMinute = item?.lastMinute?.S const storedMinute = item?.lastMinute?.S
const isNewDay = !storedDate || storedDate < today
const reqCount = Number(item?.reqCount?.N || 0) const dailyReqCount = isNewDay
const tokenCount = Number(item?.tokenCount?.N || 0) ? 0
: Number(item?.dailyReqCount?.N || 0)
const dailyTokenCount = isNewDay
? 0
: Number(item?.dailyTokenCount?.N || 0)
const tpmCount = const tpmCount =
storedMinute !== currentMinute storedMinute !== currentMinute
? 0 ? 0
: Number(item?.tpmCount?.N || 0) : Number(item?.tpmCount?.N || 0)
// Determine which limit was exceeded // Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) { if (limits.requests > 0 && dailyReqCount >= limits.requests) {
return { return {
allowed: false, allowed: false,
type: "request", type: "request",
error: "Daily request limit exceeded", error: "Daily request limit exceeded",
used: reqCount, used: dailyReqCount,
limit: limits.requests, limit: limits.requests,
} }
} }
if (limits.tokens > 0 && tokenCount >= limits.tokens) { if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) {
return { return {
allowed: false, allowed: false,
type: "token", type: "token",
error: "Daily token limit exceeded", error: "Daily token limit exceeded",
used: tokenCount, used: dailyTokenCount,
limit: limits.tokens, limit: limits.tokens,
} }
} }
@@ -164,7 +210,7 @@ export async function checkAndIncrementRequest(
} }
// Condition failed but no limit clearly exceeded - race condition edge case // Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race) // Fail safe by allowing (could be a reset race)
console.warn( console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`, `[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
) )
@@ -187,7 +233,7 @@ export async function checkAndIncrementRequest(
/** /**
* Record token usage after response completes. * Record token usage after response completes.
* Uses composite key (PK=user, SK=date) for per-day tracking. * Uses atomic operations to update both daily token count and TPM count.
* Handles minute boundaries atomically to prevent race conditions. * Handles minute boundaries atomically to prevent race conditions.
*/ */
export async function recordTokenUsage( export async function recordTokenUsage(
@@ -198,27 +244,24 @@ export async function recordTokenUsage(
if (!client || !TABLE) return if (!client || !TABLE) return
if (!Number.isFinite(tokens) || tokens <= 0) return if (!Number.isFinite(tokens) || tokens <= 0) return
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString() const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try { try {
// Try to update for same minute OR new item (most common cases) // Try to update assuming same minute (most common case)
// Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches) // Uses condition to ensure we're in the same minute
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { Key: { PK: { S: `IP#${ip}` } },
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: UpdateExpression:
"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens", "SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens",
ConditionExpression: ConditionExpression: "lastMinute = :minute",
"attribute_not_exists(lastMinute) OR lastMinute = :minute", ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: { ExpressionAttributeValues: {
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":tokens": { N: String(tokens) }, ":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
}, },
}), }),
) )
@@ -229,15 +272,14 @@ export async function recordTokenUsage(
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { Key: { PK: { S: `IP#${ip}` } },
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: UpdateExpression:
"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens", "SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: { ExpressionAttributeValues: {
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":tokens": { N: String(tokens) }, ":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
}, },
}), }),
) )

View File

@@ -243,9 +243,6 @@
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server Default",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown", "onlyVerifiedShown": "Only verified models are shown"
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
} }
} }

View File

@@ -243,9 +243,6 @@
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示", "onlyVerifiedShown": "検証済みのモデルのみ表示"
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
} }
} }

View File

@@ -243,9 +243,6 @@
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器默认",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型", "onlyVerifiedShown": "仅显示已验证的模型"
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
} }
} }

View File

@@ -40,7 +40,6 @@ export interface MultiModelConfig {
version: 1 version: 1
providers: ProviderConfig[] providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID selectedModelId?: string // Currently selected model's UUID
showUnvalidatedModels?: boolean // Show models that haven't been validated
} }
// Flattened model for dropdown display // Flattened model for dropdown display